diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87f99f2b6..9ba4f0b89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,38 +2,16 @@ name: Run Python Tests on: push jobs: - ubuntu_37: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Install Python 3 - uses: actions/setup-python@v1 - with: - python-version: 3.7 - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Prepare config folder - run: mv config_sample config - - - name: Run tests - run: python test.py - - ubuntu_38: + ubuntu_39: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install Python 3 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | @@ -46,16 +24,16 @@ jobs: - name: Run tests run: python test.py - ubuntu_39: + ubuntu_310: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install Python 3 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" - name: Install dependencies run: | @@ -68,16 +46,16 @@ jobs: - name: Run tests run: python test.py - ubuntu_310: + ubuntu_311: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install Python 3 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" - name: Install dependencies run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 166bb4654..022b2f3dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -820,3 +820,62 @@ ### 220926a (4.3.5-post1) * Fixed area list loading failing if there was a player in the process of joining a server + +## 221123a (5.0.0) +* Increased minimum Python version required to 3.9, added support for Python 3.11 +* Added hubs. Each hub has a unique numerical ID and may have its own areas, backgrounds, characters, music, zones and trials. Changes to any of these in a particular hub do not affect other hubs. Commands associated include + - /hub_create + - /hub_end + - /hub_info + - /hub_rename +* All commands that are run that involve IDs or names of players, areas, backgrounds, characters, music, zones and trials as arguments (either explicitly or implicitly) now assume those IDs or names are IDs or names within the current hub +* Added a first-list-item to switch between areas in hub view and hubs view in the area list +* A player may change between hubs by running /hub followed by the hub ID, or by double-clicking their hub of interest in the hubs view. Changing hub is equivalent to changing to the default area of a hub +* Players may now log in as GMs of a hub if they put in the hub password after /logingm. Hubs generate a random password when created that can be distributed and changed. Commands associated include + - /hub_password + - /hub_password_info +* GMs are now tied to the hub they logged in at. GMs that change hubs will lose their GM status automatically +* Server dumps now also include background, character, and DJ list information for each hub, including current file, previous file, and file contents +* Introduced the concept of participant and non-participant characters (currently a "character provided in a character list" and one that is not respectively) +* Redid trial and nonstop debates +* Json files within `storage` now use indents and new lines for better human readability (this is backwards compatible with 4.3.x) +* Removed leftover concepts of an area casing status and area ownership +* Added text in change area message indicating whether the area seems populated or not. Populated means being able to see at least one player othen than yourself in /look +* Added commands to automatically get area descriptions if possible when moving to a new area (all off by default) via + - /autoglance + - /zone_autoglance +* Added /pm_gms, which sends a personal message to all other GMs in the hub that have not muted PMs +* Added commands to modify the IC text that appears if an area is marked as noteworthy via + - /noteworthy_set + - /noteworthy_info +* Added additional restrictions to the range of allowed values for "afk_sendto" and "afk_delay" +* Made /refresh a GM+ command rather than a mod command (but it only affects your current hub) +* Made /area_kick with a single argument move the target to your area rather than the hub default area +* Renamed /area_kick to /summon, and replaced all "kicking" indications it generated with "summoning" indications +* Renamed the folders `config/background_lists` and `config/character_lists` to `config/bg_lists` and `config/char_lists` respectively to maintain consistency with several existing commands +* Improved README installation instructions so that it prompts users to try and test whether Python was properly set up in path +* Fixed timer end notification showing the OOC name of the timer initiator as the timer name rather than the actual name +* Fixed all notecard commands having an inconsistent output when used with /help_more +* Fixed /showname_set erroring out if it attempted to show an error message +* Fixed /paranoia showing an error message with wrong valid percentage boundaries if an out of bounds paranoia level was input +* Fixed /zone_paranoia having an inconsistent parameter description when used with /help_more +* Fixed server dumps not attempting to show more information about remaining objects in some object list if it failed to display some object +* Fixed players with client versions that do not allow character list reloading not being forced to spectator after a different character list was loaded +* Fixed AO 2.10 being marked as not supporting character list reloading +* Fixed /clock_unpause not sending current clock details to non-GM players +* Removed unused `server/area_manager_revamp.py` and `server/steptimer_manager.py` +* Removed the old names of the following commands. Please use the new ones: + - /clock_cancel: /clock_end + - /lurk_cancel: /lurk_end + - /party_disband: /party_end + - /timer_cancel: /timer_end + - /zone_delete: /zone_end +* Removed RP mode: It is now assumed always on +* Removed /gmlock and /gmunlock (different from /lock and /unlock) +* Removed unused configuration settings from `config/config.yaml` +* Removed the following configuration files: + - The allowed iniswaps override file `config/iniswaps.yaml`. + - The compatibility bridge file `config/110_new_music.yaml`. + - The area lists file `config/area_lists.yaml` + - The music lists file `config/music_lists.yaml` + - The area templates file `config/area_templates.yaml` diff --git a/CODINGPRACTICES.md b/CODINGPRACTICES.md index d969b016b..a22b6cfc2 100644 --- a/CODINGPRACTICES.md +++ b/CODINGPRACTICES.md @@ -36,8 +36,9 @@ This document will present best coding practices and contribution standards coll - `M` if the development branch is for a major release (e.g. `M200319b`). - `m` if the development branch is for a minor release (e.g. `m200229c`). - `p` if the development branch is for a post-release (e.g. `p191231e`). - - If a release is meant to be made public effective immediately, there should be no letters at the beginning (e.g. `200319a`). - - The date should correspond to its equivalent EST/EDT date, whichever is active in the United States (so a person three hours behind EST, whose last commit in the pull request is at 10 pm, should add one to their day as it would be the next day in EST). + - If a release is targeted towards an approved development branch and is meant to be widely tested, include the letter `O` after the letter described in the prior section (e.g. `PO221031a`). + - If a release is meant to be stable and made public effective immediately, there should be no letters at the beginning (e.g. `200319a`). + - The date should correspond to the commit date in local time. - For example, a January 13, 2019 EST commit could be labeled `190113e`; and a December 7, 2020 EST commit could be labeled `201207a`. - The final letter should correspond to the `i`-th letter of the English alphabet, where `i` corresponds to however many releases were pushed to the target branch on that date. - For example, `201207a` means the release is the first one to the master branch of December 7, 2020; and `M190101c` is the third commit to the development branch in major release stage of January 1, 2019. @@ -55,4 +56,4 @@ This document will present best coding practices and contribution standards coll ## Best coding practices for YAML code * Indent with 2 spaces if needed. * Add comments describing keys and possible values. -* Developers are encouraged to modify existing code that violates these guidelines so that they satisfy them. Seeing code that does not follow the guidelines is not an excuse not to use them for new code. \ No newline at end of file +* Developers are encouraged to modify existing code that violates these guidelines so that they satisfy them. Seeing code that does not follow the guidelines is not an excuse not to use them for new code. diff --git a/README.md b/README.md index 4680b9ed2..32a6e3889 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Python-based server for Danganronpa Online. It is a fork from [tsuserver3](https://github.com/AttorneyOnline/tsuserver3) which is targeted towards Attorney Online. -Requires Python 3.7-3.10, PyYAML and aiohttp (follow instructions below to install). +Requires Python 3.9-3.11, PyYAML and aiohttp (follow instructions below to install). ## How to use @@ -15,12 +15,17 @@ It is highly recommended you read through all the installation steps first befor - You can download Python from its official website [here](https://www.python.org/downloads/). If you already have Python installed, check that your Python version satisfies the version requirement listed above. - If prompted during the installation to add `python` as a PATH environment variable, accept this option. You may see this option appear on the very first screen during the installation process. - If you know what a virtual environment is and your system supports it, it is recommended that you use one, such as [Anaconda](https://www.continuum.io/downloads) for Windows, or [virtualenv](https://virtualenv.pypa.io/en/stable/) for everyone else (it runs itself using Python). If you do not know what a virtual environment is, you may skip this point. - - If you have Python 3.6 or lower, you will be prompted on server launch to update to a newer version of Python. That is because the server requires Python 3.7 or higher. Follow instructions under Updating to update your Python version. + - If you have Python 3.8 or lower, you will be prompted on server launch to update to a newer version of Python. That is because the server requires Python 3.9 or higher. Follow instructions under Updating to update your Python version. * Open Command Prompt, PowerShell or your preferred terminal, and change to the directory where you downloaded TsuserverDR to. You can do this in two ways: - On Windows 10 or higher, go up one folder above the TsuserverDR folder, Shift + right click the TsuserverDR folder, and click `Open PowerShell window here` or `Open in Terminal`. This is the easiest method. Alternatively... - On most operating systems, copy the path of the TsuserverDR folder, open the terminal, and type in `cd "[paste here]"`, excluding the brackets, but including the quotation marks if the path contains spaces. +* Make sure Python is properly installed by typing `python` in the terminal and pushing Enter. + - If text that starts with `Python 3.11.0 (main, ` or similar appears, you are all set with this step. Push Ctrl+Z followed by Enter to continue to the next step if you are on Windows; and Ctrl+D if you are on Linux. + - If text that starts with `The term 'python' is not recognized as the name of a cmdlet` or similar, Python wasn't properly added to path. Follow the instructions [here](https://realpython.com/add-python-to-path/#how-to-add-python-to-path-on-windows) to try and fix the issue (this is for Windows, instructions for other operating systems are also available in the same website if you scroll further down). + - If on Windows and a separate window opens that leads to the Microsoft Store, Windows may have added an execution alias. Follow the instructions [here](https://stackoverflow.com/a/58773979) to try and remove this alias. + * Install all dependencies by typing in the following two commands in the terminal you just opened: ``` python -m pip install --upgrade pip @@ -37,7 +42,7 @@ It is highly recommended you read through all the installation steps first befor * Rename the folder `config_sample` to `config` and edit the values in the provided YAML files to your liking. Be sure to check the YAML files for syntax errors after you are done. *Use spaces only; do not use tabs.* -* Open or forward the port that you would like players to connect to. The port number is available in `config/config.yaml` next to `port`, and you may change it to something else if you want to. The port that you choose must be made to accept TCP connections. This whole process may involve you doing some (or all) of the following: +* If you intend to have players outside of your network connect to your server, open or forward the port that you would like them to connect to. The port number is available in `config/config.yaml` next to `port`, and you may change it to something else if you want to. The port that you choose must be made to accept TCP connections. This whole process may involve you doing some (or all) of the following: - If you are hosting your server in your own device: go to your router settings, and proceed to open/forward/allow inbound connections into the port. You can typically find instructions for this by finding the brand and model name of your router, and searching "brand + model + port forwarding". - If you are hosting your server in an external device (such as a VPS): find its networking settings, and proceed to open/forward/allow inbound connections into the port. You can typically find instructions for this by searching something like this "your VPS + open/forward/allow inbound connections into the port". - Go to your operating system firewall settings, and proceed to create a rule to allow inbound connections into the port. You can typically find instructions for this by searching "your operating system + create inbound port rule". @@ -52,33 +57,33 @@ It is highly recommended you read through all the installation steps first befor * If everything was set up correctly, you will see something like this appear: ``` -[2022-09-12T10:20:20]: Starting... -[2022-09-12T10:20:20]: Launching TsuserverDR 4.3.5 (220912a)... -[2022-09-12T10:20:20]: Loading server configurations... -[2022-09-12T10:20:20]: Server configurations loaded successfully! -[2022-09-12T10:20:20]: Starting a nonlocal server... -[2022-09-12T10:20:20]: Server started successfully! -[2022-09-12T10:20:21]: Server should be now accessible from address 192.0.2.0 and port 50000. +[2022-11-23T10:20:20]: Starting... +[2022-11-23T10:20:20]: Launching TsuserverDR 5.0.0 (221123a)... +[2022-11-23T10:20:20]: Loading server configurations... +[2022-11-23T10:20:20]: Server configurations loaded successfully! +[2022-11-23T10:20:20]: Starting a nonlocal server... +[2022-11-23T10:20:20]: Server started successfully! +[2022-11-23T10:20:21]: Server should be now accessible from address 192.0.2.0 and port 50000. ``` * If you are listing your server in the Attorney Online master server, make sure its details are set up correctly. In particular, make sure that your server name and description are correct, as that is how players will find your server. If everything was set up correctly, you will see something like this appear: ``` -[2022-09-12T10:20:21]: Attempting to connect to the master server at https://servers.aceattorneyonline.com/servers with the following details: -[2022-09-12T10:20:21]: *Server name: My First DR Server -[2022-09-12T10:20:21]: *Server description: This is my flashy new DR server -[2022-09-12T10:20:22]: Connected to the master server. +[2022-11-23T10:20:21]: Attempting to connect to the master server at https://servers.aceattorneyonline.com/servers with the following details: +[2022-11-23T10:20:21]: *Server name: My First DR Server +[2022-11-23T10:20:21]: *Server description: This is my flashy new DR server +[2022-11-23T10:20:22]: Connected to the master server. ``` - The server will make a single ping to [ipify](https://api.ipify.org) in order to obtain its public IP address. If it fails to do that, it will let you know that, as it means there is probably something wrong with your internet connection and that other players may not be able to connect to your server. - - Successful connection or getting a spot in the master server list does not imply that your server will be accessible to other players. In particular, you must make sure that your external port in `config\config.yaml` is open and accepting connections, which usually involves a combination of router and firewall settings. In case of doubt, you can use websites such as [Can You See Me](https://canyouseeme.org) to check if your port is visible. + - Successful connection or getting a spot in the master server list does not imply that your server will be accessible to other players. In particular, you must make sure that your external port in `config/config.yaml` is open and accepting connections, which usually involves a combination of router and firewall settings. In case of doubt, you can use websites such as [Can You See Me](https://canyouseeme.org) to check if your port is visible. * To stop the server, press Ctrl+C once from your terminal. This will initiate a shutdown sequence and notify you when it is done. If the shutdown finished successfully, you will see something like this appear: ``` -[2022-09-12T22:23:04]: You have initiated a server shut down. -[2022-09-12T22:23:04]: Kicking 12 remaining clients. -[2022-09-12T22:23:04]: Server has successfully shut down. +[2022-11-23T22:23:04]: You have initiated a server shut down. +[2022-11-23T22:23:04]: Kicking 12 remaining clients. +[2022-11-23T22:23:04]: Server has successfully shut down. ``` * If you do not see anything after a few seconds of starting a shutdown, you can try spamming Ctrl+C to try and force a shutdown or directly close out your terminal. This is not recommended due to the cleanup process not finishing correctly but it is doable. @@ -93,7 +98,7 @@ It is highly recommended you read through all the installation steps first befor - This process will not overwrite your server configurations inside the `config` folder, your existing logs inside the `logs` folder, or the user information inside the `storage` folder. However, it will overwrite other files including the Python files inside the `server` folder. Therefore, make sure to save backups of those files before overwriting in case you have modified them and wish to keep an archive of your changes. -* If you want to update **Python** itself, you can get the latest Python download [from their website here](https://www.python.org/downloads/) and then follow the instructions under the Installing section in this readme. To check your current version of Python, you can run ``python`` on its own and look at the first line. The latest stable major Python release is *Python 3.10* as of October 4, 2021. +* If you want to update **Python** itself, you can get the latest Python download [from their website here](https://www.python.org/downloads/) and then follow the instructions under the Installing section in this readme. To check your current version of Python, you can run ``python`` on its own and look at the first line. The latest stable major Python release is *Python 3.11* as of October 24, 2022. - Please follow the installation instructions again even if you had successfully ran a server before, because your new Python installation may be missing libraries that TsuserverDR expects there to exist. You should not need to change any server configuration files though. - In general, updating to a Python version beyond what is specified as supported may lead to unstable behavior, so for active servers try to keep your Python version among the ones specifically labeled as supported. @@ -111,8 +116,11 @@ Additional notes are listed at the end of the command list. Unless otherwise spe - Displays the current area ambient sound effect. * **area** "area number" - Moves you to an area by its numerical ID if it is reachable from your own, or displays all areas if not given a number. +* **autoglance** + - Toggles area descriptions (if present) being sent automatically or not when you move areas. + - No messages will be sent if your area's lights are turned off or you are blind. * **autopass** - - Toggles enter/leave messages being sent automatically or not to users in the current area, including original/target areas. + - Toggles enter/leave messages being sent automatically or not to users in your area, including original/target areas. - Messages will not be sent if sneaking. Altered messages will be sent if the area's lights are turned off. * **bg** "background" - Changes the current background. @@ -155,6 +163,8 @@ Additional notes are listed at the end of the command list. Unless otherwise spe - Shows the current characters in your area, including their showname if they have set it. * **getareas** - Shows all characters in all areas reachable from your own, including their showname if they have set it. +* **hub** "hub number" + - Moves you to the default area of a hub by its numerical ID, or displays all hubs if not given a number. * **ignore** "ID/char name/edited-to character/showname/char showname/OOC name" - Marks a target as ignored, so you will no longer receive any IC messages from them. - The target is not notified of you marking them as ignored. @@ -175,7 +185,7 @@ Additional notes are listed at the end of the command list. Unless otherwise spe * **logincm** "password" - Makes you a Community Manager. * **logingm** "password" - - Makes you a GM. + - Makes you a Game Master. * **logout** - Logs you out of the rank you have, if any. * **look** @@ -188,8 +198,6 @@ Additional notes are listed at the end of the command list. Unless otherwise spe - Sets your music list to the given one, or restores the original one if not given any. * **music_list_info** - Returns your current music list. -* **music_lists** - - Lists all available music lists as established in `config/music_lists.yaml`. * **notecard** "content" - Sets the content of your notecard. * **notecard_clear** @@ -239,6 +247,7 @@ Additional notes are listed at the end of the command list. Unless otherwise spe - Returns "Pong", used to check for server connection. * **play** "song.extension" - Plays a song, provided the area you are in allows non-staff members to run this command. + - If the song is within some folders within the client music folder, such folders must be included separated by "/" (e.g. "trial/Trial Start.opus" to play "Trial Start.opus" within the "trial" folder of the client music folder). * **pm** "ID/char name/edited-to character/showname/char showname/OOC name" "message" - PMs the target. * **pos** "position" @@ -289,6 +298,8 @@ Additional notes are listed at the end of the command list. Unless otherwise spe - Changes your setting to have forward sprites mode off (your character sprites are not sent to anyone, everyone sees the last sprites they last saw) or on (your character appears normally). By default it is on. * **toggle_global** - Changes your setting to receive global messages. By default it is on. +* **toggle_music_list_default** + - Changes your setting to see which music list to see when no personal music list is active: current hub or server default. By default it is current hub. * **toggle_pm** - Changes your setting to receive PMs. By default it is on. * **toggle_shownames** @@ -331,9 +342,11 @@ GMs can: - Changes the ambient sound effect of your current area. * **ambient_end** - Clears the ambient sound effect of your current area. -* **area_kick** "ID" "area number" - - Kicks target from your area to the intended area and remove them from its invite-list. - - If not given a target area, it will use the server's default area (usually area 0). +* **area_list** "area list" + - Sets the area list of your hub. + - If not given an area list, it will use the default file `config/areas.yaml`. +* **area_list_info** + - Returns the area list of your hub. * **autopass** "ID" - Toggles enter/leave messages being sent automatically or not to users in the current area, including original/target areas, whenever the target moves. - Messages will not be sent if sneaking. Altered messages will be sent if the area's lights are turned off. @@ -357,18 +370,28 @@ GMs can: - Sets the current area to have bloodtrails leading to the listed areas. If no areas are given, the area is set to have an unconnected pool of blood. * **bloodtrail_smear** "area 1", "area 2", ... - Smears the blood trails in the given areas (or the current one if not given any areas). +* **bg_list** "list" + - Sets the background list of your hub. + - If not given a background list, it will use the default file `config/backgrounds.yaml`. +* **bg_list_info** + - Returns the background list of your hub. * **bg_period** "period name" "bg name" - Sets the special background to be displayed in the area whenever there is a clock in the area with the given period. * **bg_period_end** "period name" - Removes the special background to be displayed in the area whenever there is a clock in the area with the given period, so that it instead displays the normal background of the area. * **can_passagelock** - - Changes the current area's setting to allow non-staff members to change passages starting in the area with /bilock or /unilock. By default area setting is indicated in the server's area list. + - Changes the current area's setting to allow non-staff members to change passages starting in the area with /bilock or /unilock. By default area setting is indicated in your hub's area list. * **can_rollp** - - Changes the current area's setting to allow non-staff members to do /rollp. By default area setting is indicated in the server's area list. + - Changes the current area's setting to allow non-staff members to do /rollp. By default area setting is indicated in your hub's area list. * **can_rpgetarea** - - Changes the current area's setting to allow RP users to use /getarea. By default area setting is indicated in the server's area list. + - Changes the current area's setting to allow RP users to use /getarea. By default area setting is indicated in your hub's area list. * **can_rpgetareas** - - Changes the current area's setting to allow RP users to use /getareas. By default area setting is indicated in the server's area list. + - Changes the current area's setting to allow RP users to use /getareas. By default area setting is indicated in your hub's area list. +* **char_list** "list" + - Sets the character list of your hub. + - If not given a character list, it will use the default file `config/characters.yaml`. +* **char_list_info** + - Returns the character list of your hub. * **char_restrict** "character name" - Changes the restricted status of a character in the current area. - If a character is restricted, only GMs and above can use the character in the current area. @@ -407,6 +430,11 @@ GMs can: - Obtains the last 20 rolls from a target, or your last 20 rolls if not given a target. * **dicelog_area** "area" - Obtains the last 20 rolls from an area by ID or name, or the last 20 rolls of your area if not given one. +* **dj_list** "music list" + - Sets the music list of your hub. + - If not given a music list, it will use the default file `config/music.yaml`. +* **dj_list_info** + - Returns the music list of your hub. * **follow** "ID" - Starts following a target. If the target changes areas, you will automatically follow them there. * **gag** "ID" @@ -418,8 +446,6 @@ GMs can: - Sends subsequence IC messages to the area range described above. Can take either area IDs or area names. * **globalic_pre** "prefix" - Ensures only IC messages that start with the prefix are sent to the preestablished area range through /globalic (otherwise, just to the current area), or removes the need for a prefix if not given one. -* **gmlock** - - Locks your area. Prevents CMs and normal users from entering. WARNING: Pending deprecation. * **gmself** - Logs all opened multiclients as GM. * **guide** "ID/char name/edited-to character/showname/char showname/OOC name" "message" @@ -431,6 +457,16 @@ GMs can: - If name is given, the handicap announcement will use it as the name of the handicap. - If announce if over is set to any of "False, false, 0, No, no", no announcements will be sent to the player indicating that they may now move areas. - If the player had an existing handicap, it will be overwritten with this one. +* **hub_end** + - Deletes your hub. +* **hub_info** + - Returns information about your hub. +* **hub_password** "password" + - Changes the password of your hub. +* **hub_password_info** + - Gets the password of your hub. +* **hub_rename** "name" + - Changes the name of your hub to the given name, or clears it if not given any. * **iclock** - Changes the IC lock status of the current area. - If the area has an IC lock, only GMs and above will be able to send IC messages. @@ -473,7 +509,12 @@ GMs can: - Tallies the contents of all notecards of players in the area and reveals the count to all players in your current area. - This does not reveal the people who wrote particular notecards. * **noteworthy** - - Changes the noteworthy status of the area. + - Changes the noteworthy status of the area. +* **noteworthy_info** + - Returns the noteworthy status and noteworthy text of an area. +* **noteworthy_set** "text" + - Changes the noteworthy text of the area to the given text, or a default one if not given any. + - Noteworthy text can be changed independently of the noteworthy status of an area. * **nsd** "time" - Starts an NSD part of your current trial with all players in the area part of your trial, making you leader of the NSD. - The NSD will have a given time limit in seconds, or no time limit if not given a time. Debates with a time limit will be automatically halted once the timer runs out. @@ -521,18 +562,20 @@ GMs can: - Restores passage locks that start in the areas in the given area range to their original status, or just the ones in the current area if not given a range. * **play** "song.extension" - Plays a song, even if not in the server music list. + - If the song is within some folders within the client music folder, such folders must be included separated by "/" (e.g. "trial/Trial Start.opus" to play "Trial Start.opus" within the "trial" folder of the client music folder). * **poison** "ID" "initials of effects" "length" - Applies a poison to the target that will inflict them in the given length of time in seconds the given effects. * **pos_force** "position" "ID" - Changes the IC position of a target by ID to the given one, or the one of all players in an area if not given a target. +* **refresh** + - Reloads your hub's character, music and background lists. * **reveal** "ID" - Reveals a target if they were previously sneaking. - Also restores their formerly assigned handicap if they had one that was shorter than the server's automatic sneaking handicap. - If no ID is given, target is yourself. * **rplay** "song.extension" - Plays a song in all areas reachable from the current one. -* **rpmode** "on/off" - - Toggles RP mode. + - If the song is within some folders within the client music folder, such folders must be included separated by "/" (e.g. "trial/Trial Start.opus" to play "Trial Start.opus" within the "trial" folder of the client music folder). * **scream_range** - Returns the areas that can listen to screams sent from the current area. * **scream_set** "area" @@ -554,6 +597,8 @@ GMs can: - If no ID is given, target is yourself. * **sneakself** - Sneaks all opened multiclients that can be sneaked. +* **summon** "ID" "area number" + - Summons target from their area to the intended area, or to your area if not given an area, and remove them from the invite list of their old area. * **st** "message" - Sends a message to all active staff members. * **status_set_other** "ID/char name/edited-to character/showname/char showname/OOC name" "status" @@ -608,7 +653,7 @@ GMs can: * **uninvite** "ID/char name/edited-to character/showname/char showname/OOC name" - Removes a target from your locked area's invite list, so that if they leave, they will not be allowed back until the area is unlocked. * **unlock** - - Unlocks an area, provided the lock came as a result of /gmlock or /lock. + - Unlocks an area, provided the lock came as a result of /lock. * **whereis** "ID" - Obtains the area a target is. * **whois** "ID/char name/showname/OOC name" @@ -622,6 +667,8 @@ GMs can: - Changes the ambient sound effect of all areas of the zone you are watching. * **zone_ambient_end** - Clears the ambient sound effect of all areas of the zone you are watching. +* **zone_autoglance** + - Changes the autoglance status of the zone you are watching. If turned on, turns autoglance on for players in an area part of a zone and players that later join; otherwise, it turns it off for players in an area part of the zone * **zone_autopass** - Changes the autopass status of the zone you are watching. If turned on, turns autopass on for players in an area part of a zone and players that later join; otherwise, it turns it off for players in an area part of the zone * **zone_end** @@ -654,6 +701,7 @@ GMs can: - Gets the zone paranoia level of the zone you are watching. * **zone_play** - Plays a track in all areas in the zone you are watching. + - If the song is within some folders within the client music folder, such folders must be included separated by "/" (e.g. "trial/Trial Start.opus" to play "Trial Start.opus" within the "trial" folder of the client music folder). * **zone_remove** "area" - Removes an area by name or ID from the zone you are watching. * **zone_tick** "chat tick rate" @@ -669,32 +717,12 @@ GMs can: ### Community Manager Commands -* **area_kick** "ID/IPID" "area number" - - Kicks target from your area to the intended area and remove them from its invite-list. - - If not given a target area, it will use the server's default area (usually area 0). -* **area_list** "area list" - - Sets the server's current area list. - - If not given an area list, it will use the default file `config/areas.yaml`. -* **area_list_info** - - Returns the server's current area list. -* **area_lists** - - Lists all available area lists as established in `config/area_lists.yaml`. -* **bg_list** "list" - - Sets the server's current background list. - - If not given a background list, it will use the default file `config/backgrounds.yaml`. -* **bg_list_info** - - Returns the server's current background list. * **blockdj** "ID/IPID" - Mutes the target from changing music. * **charlog** "ID/IPID" - Lists all character changes (including iniswaps and character name changes) a target has gone through since connecting, including the time they were changed. -* **char_list** "list" - - Sets the server's current character list. - - If not given a character list, it will use the default file `config/characters.yaml`. -* **char_list_info** - - Returns the server's current character list. * **cleargm** "ID" - - Logs out the target from their GM rank, or all GMs in the server if not given a target, and puts them in RP mode if needed. + - Logs out the target from their GM rank, or all GMs in the server if not given a target. * **g** "message" - Sends a serverwide message, even if the current area disallows sending global messages. * **getarea** @@ -708,6 +736,16 @@ GMs can: - Sets a movement handicap on a client by ID or IPID so that they need to wait a set amount of time in seconds between changing areas. - If name is given, the handicap announcement will use it as the name of the handicap. - If announce if over is set to any of "False, false, 0, No, no", no announcements will be sent to the player indicating that they may now move areas. +* **hub_create** "name" + - Creates a new hub with given name, or with a default generated name otherwise. +* **hub_end** "hub" + - Deletes the hub by numerical ID, or your hub if not given any. +* **hub_info** "hub" + - Returns information about the hub by numerical ID, or your hub if not given any. +* **hub_password_info** "hub" + - Returns the password of the hub by numerical ID, or your hub if not given any. +* **hub_rename** "name" + - Changes the name of your hub to the given name, or clears it if not given any. * **invite** "ID/IPID/char name/edited-to character/showname/char showname/OOC name" - Adds target to the invite list of your area. * **kick** "ID/IPID" @@ -732,6 +770,8 @@ GMs can: * **sneak** "ID/IPID" - Sets a target to be sneaking if they were visible. - If the target was subject to a handicap shorter than the server's automatic sneak handicap length, they will be imposed this handicap. +* **summon** "ID/IPID" "area number" + - Summons target from their area to the intended area, or to your area if not given an area, and remove them from the invite list of their old area. * **transient** "ID/IPID" - Changes a player's ability to ignore passage locks and thus access all areas from any given area. By default it is off. * **unblockdj** "ID/IPID" @@ -779,8 +819,6 @@ GMs can: - Sends an area OOC message with mod tag. * **modlock** - Locks your area. Prevents GMs, CMs and normal users from entering. -* **refresh** - - Reloads the server's default character, music and background lists. * **showname_freeze** - Changes the ability of non-staff members of being able to change or remove their own shownames. * **showname_nuke** @@ -794,7 +832,7 @@ GMs can: * **undisemvowel/undisemconsonant/ungimp/unremove_h** "ID/IPID" - Undoes correlating command. * **unlock** - - Unlocks an area, provided the lock came as a result of /gmlock, /lock or /modlock. + - Unlocks an area, provided the lock came as a result of /lock or /modlock. ### Debug commands @@ -824,7 +862,6 @@ Commands without (D) are aliases to commands and can be freely used (subject to * **yell**: Same as /scream. * **zg**: Same as /zone_global. * **zi**: Same as /zone_info. -* **timer_cancel**: Same as /timer_end. (D) (Deprecated July 5, 2021) * **fa**: Same as /files_area. * **l**: Same as /look. * **forcepos**: Same as /pos_force. @@ -835,13 +872,10 @@ Commands without (D) are aliases to commands and can be freely used (subject to #### GM+ +* **area_kick**: Same as /summon. * **loginrp**: Same as /logingm. * **slit**: Same as /bloodtrail. * **unsneak**: Same as /reveal. -* **clock_cancel**: Same as /clock_end. (D) (Deprecated July 5, 2021) -* **lurk_cancel**: Same as /lurk_end. (D) (Deprecated July 5, 2021) -* **zone_delete**: Same as /zone_end. (D) (Deprecated July 5, 2021) -* **rpmode**: (D) (Deprecated May 20, 2022) ### Notes @@ -853,7 +887,7 @@ Commands without (D) are aliases to commands and can be freely used (subject to - **IP**: the IP address of the player. - **OOC Name**: the username of the player in the OOC chat. * **Note 2**: some commands include commas (,) between the parameters. If that is the case, the command expects you to actually use the commas between the parameters. If for whatever reason your parameter also has a comma followed by a space, you can include it by using ,\ (so 'Hello, world' becomes 'Hello,\ world'). -* **Note 3**: additional documentation for the commands can be found in `config\commands.py` and consulting the docstrings. For example, to get additional information for /help, you would look for `ooc_cmd_help` and look for the associated text. +* **Note 3**: additional documentation for the commands can be found in `config/commands.py` and consulting the docstrings. For example, to get additional information for /help, you would look for `ooc_cmd_help` and look for the associated text. ## License diff --git a/config_sample/110_new_music.yaml b/config_sample/110_new_music.yaml deleted file mode 100644 index 4c6648faf..000000000 --- a/config_sample/110_new_music.yaml +++ /dev/null @@ -1,564 +0,0 @@ -- "dro_castlevania/Belmont The Legend.opus" -- "dro_castlevania/Blood Drops.opus" -- "dro_castlevania/Crystal Teardrops.opus" -- "dro_castlevania/Deep Question from Green Owl.opus" -- "dro_castlevania/Etude For The Killer.opus" -- "dro_castlevania/Marble Gallery.opus" -- "dro_castlevania/Offense and Defense.opus" -- "dro_castlevania/Prelude to the Black Abyss.opus" -- "dro_castlevania/Prison of Eternal Torture.opus" -- "dro_castlevania/Rainbow Cemetery.opus" -- "dro_castlevania/Silent Madness.opus" -- "dro_castlevania/Still Raining Divine Bloodlines Reprise.opus" -- "dro_castlevania/The Tragic Prince.opus" -- "dro_castlevania/Thrashard in the Cave.opus" -- "dro_castlevania/Wandering Ghosts.opus" -- "dro_cotnd/CotND (DR1) - BOX 15 (3-1 cold).opus" -- "dro_cotnd/CotND (DR1) - BOX 16 (3-1 hot).opus" -- "dro_cotnd/CotND (DR1) - DANGANRONPA (lobby).opus" -- "dro_cotnd/CotND (DR1) - DISTRUST (4-1).opus" -- "dro_cotnd/CotND (DR1) - Discussion -HEAT UP- (1-3).opus" -- "dro_cotnd/CotND (DR1) - Junk Food for a Dashing Youth (1-2).opus" -- "dro_cotnd/CotND (DR1) - Mr. Monokuma's Extracurricular Lesson (boss1).opus" -- "dro_cotnd/CotND (DR1) - Punishment (boss3).opus" -- "dro_cotnd/CotND (DR1) - Supplementary Lessons for the Mysterious (3-3 hot).opus" -- "dro_cotnd/CotND (DR1) - Supplementary Lessons for the Unlucky (3-3 cold).opus" -- "dro_cotnd/CotND (DR1) - Trial Underground (boss4).opus" -- "dro_cotnd/CotND (DRAE) - Alice in the Children's Land (2-3).opus" -- "dro_cotnd/CotND (DRAE) - It's a Monokuma World (3-2 cold).opus" -- "dro_cotnd/CotND (DRAE) - It's a Monokuma World (3-2 hot).opus" -- "dro_cotnd/CotND (DRAE) - Last VERSUS (1-1).opus" -- "dro_cotnd/CotND (DRAE) - Monster That Shouts Its Love in the Center of Hell (Boss2).opus" -- "dro_cotnd/CotND (DRAE) - Wonderful Dead (2-2).opus" -- "dro_cotnd/CotND (SDR2) - 5th Island Theme (2-1).opus" -- "dro_cotnd/CotND (SDR2) - Class Trial (Future Edition) (4-3).opus" -- "dro_cotnd/CotND (SDR2) - Kill Command (4-2).opus" -- "dro_dr1/All All Apologies.opus" -- "dro_dr1/BOX 15.opus" -- "dro_dr1/BOX 16.opus" -- "dro_dr1/Beautiful Days.opus" -- "dro_dr1/Beautiful Dead.opus" -- "dro_dr1/Beautiful Morning.opus" -- "dro_dr1/Bullet Truth Get.opus" -- "dro_dr1/Chapter Results.opus" -- "dro_dr1/Class Trial (Dawn Edition).opus" -- "dro_dr1/Class Trial (Solar Edition) -PROTOTYPE-.opus" -- "dro_dr1/Class Trial (Solar Edition).opus" -- "dro_dr1/Class Trial (Turbulent Edition).opus" -- "dro_dr1/Climactic Reasoning -Extended Mix-.opus" -- "dro_dr1/Climactic Reasoning.opus" -- "dro_dr1/Climactic Return.opus" -- "dro_dr1/DANGANRONPA(RX-Ver. S.P.L).opus" -- "dro_dr1/DANGANRONPA.opus" -- "dro_dr1/DISTRUST.opus" -- "dro_dr1/Danganronpa!.opus" -- "dro_dr1/Desire for Execution....opus" -- "dro_dr1/Despair Pollution Noise Music.opus" -- "dro_dr1/Despair Syndrome (2).opus" -- "dro_dr1/Despair Syndrome.opus" -- "dro_dr1/Discussion -BREAK-.opus" -- "dro_dr1/Discussion -HEAT UP-.opus" -- "dro_dr1/Discussion -HOPE VS DESPAIR-.opus" -- "dro_dr1/Discussion -MIX- (EDGE Version).opus" -- "dro_dr1/E-Handbook start up.opus" -- "dro_dr1/Flashing Anagram.opus" -- "dro_dr1/Goodbye Despair School.opus" -- "dro_dr1/Gym time with Monokuma.opus" -- "dro_dr1/Junk Food for a Dashing Youth.opus" -- "dro_dr1/M.T.B..opus" -- "dro_dr1/Momomomonokuma!.opus" -- "dro_dr1/Mono-Mono Machine!.opus" -- "dro_dr1/Motorcycle Death Cage.opus" -- "dro_dr1/Mr. Monokuma's Extracurricular Lesson.opus" -- "dro_dr1/Mr. Monokuma's Lesson.opus" -- "dro_dr1/New World Order.opus" -- "dro_dr1/Phase Results.opus" -- "dro_dr1/Present Get.opus" -- "dro_dr1/Punishment Rocket.opus" -- "dro_dr1/Rare Present Get.opus" -- "dro_dr1/Report Card Update.opus" -- "dro_dr1/SUPER FINAL M.T.B..opus" -- "dro_dr1/SUPER M.T.B..opus" -- "dro_dr1/Saisei -rebuild-.opus" -- "dro_dr1/Shovel Master.opus" -- "dro_dr1/Super High School's Desperate Punishment.opus" -- "dro_dr1/Supplementary Lessons for the Mysterious.opus" -- "dro_dr1/Supplementary Lessons for the Unlucky.opus" -- "dro_dr1/That Person Saw It!.opus" -- "dro_dr1/Thousand Knocks.opus" -- "dro_dr1/To Survive.opus" -- "dro_dr1/Trial Underground.opus" -- "dro_dr1/Versailles Burning at the Stake Witch Hunt Preparations.opus" -- "dro_dr1/Weekly Despair Magazine.opus" -- "dro_dr1/Welcome Despair School.opus" -- "dro_dr2/A Dead End to the Ocean's Aroma.opus" -- "dro_dr2/ANAGRAM.NET.opus" -- "dro_dr2/All All Apologies [16bit].opus" -- "dro_dr2/Alter Ego of the New World.opus" -- "dro_dr2/Beautiful Days [Piano Arrangement].opus" -- "dro_dr2/Beautiful Ruin [16bit].opus" -- "dro_dr2/Beautiful Ruin [Summer Salt].opus" -- "dro_dr2/Beautiful Ruin.opus" -- "dro_dr2/Class Trial (Dawn Edition) - Volume Two.opus" -- "dro_dr2/Class Trial (Future Part) [With Intro].opus" -- "dro_dr2/Class Trial (Future Part).opus" -- "dro_dr2/Class Trial (Odd Edition).opus" -- "dro_dr2/Class Trial (Solar Edition) - Volume Two.opus" -- "dro_dr2/Class Trial (Turbulent Edition) - Volume Two.opus" -- "dro_dr2/DANGANRONPA SUPER MIX [2nd GIG].opus" -- "dro_dr2/DANGANRONPA SUPER MIX.opus" -- "dro_dr2/DIVE DRIVE.opus" -- "dro_dr2/DR2 Title Theme.opus" -- "dro_dr2/Deceived.opus" -- "dro_dr2/Desperate Mastermind Girl.opus" -- "dro_dr2/Discussion -B side-.opus" -- "dro_dr2/Discussion -BREAK- [2nd mix].opus" -- "dro_dr2/Discussion -HEAT UP- [2nd mix].opus" -- "dro_dr2/Discussion -HEAT UP- [8bit].opus" -- "dro_dr2/Discussion -HOPE VS DESPAIR- [2nd mix].opus" -- "dro_dr2/Ekoroshia [8bitbug].opus" -- "dro_dr2/Ekoroshia.opus" -- "dro_dr2/Fifth Island Theme.opus" -- "dro_dr2/GAME START.opus" -- "dro_dr2/HYPER P.T.A..opus" -- "dro_dr2/HYPER ULTRA P.T.A..opus" -- "dro_dr2/Hope's Breaking Noise.opus" -- "dro_dr2/I've Come to the Tropics!.opus" -- "dro_dr2/Ikoroshia [16bit].opus" -- "dro_dr2/Ikoroshia.opus" -- "dro_dr2/Justice For Our Prime Suspect!.opus" -- "dro_dr2/Let Us Sing of a Hollow Victory.opus" -- "dro_dr2/Love is Survival.opus" -- "dro_dr2/Machine Gun with Bunny Ears.opus" -- "dro_dr2/Master Monokuma's Special Class.opus" -- "dro_dr2/Monoclocktower.opus" -- "dro_dr2/Ms. Monomi's Practice Lesson.opus" -- "dro_dr2/Objection -CROSS SWORD-.opus" -- "dro_dr2/P.T.A..opus" -- "dro_dr2/Punishment feat. Dangan Island.opus" -- "dro_dr2/Punishment feat. Hell-icopter.opus" -- "dro_dr2/Punishment feat. Puppet Girl.opus" -- "dro_dr2/Punishment feat. Rocket Pants.opus" -- "dro_dr2/Punishment feat. The Arcade Rabbit.opus" -- "dro_dr2/Punishment feat. The Last Hike.opus" -- "dro_dr2/Ranked.opus" -- "dro_dr2/Received.opus" -- "dro_dr2/Searching the Twilight.opus" -- "dro_dr2/Sending This To You.opus" -- "dro_dr2/Setting Sail -departure- (Short Version).opus" -- "dro_dr2/Shukko -departure-.opus" -- "dro_dr2/Suguikoroshia.opus" -- "dro_dr2/The Day Before the Future.opus" -- "dro_dr2/The End of the Twilight.opus" -- "dro_dr2/The Truth Behind the Twilight.opus" -- "dro_dr2/Third Island Theme.opus" -- "dro_dr2/Tropical Despair.opus" -- "dro_dr2/Usamimimimi!.opus" -- "dro_dr2/Welcome to DANGAN IsLand!! [OP Ver].opus" -- "dro_dr2/Welcome to DANGAN Island!!.opus" -- "dro_dr2/Welcome, Monobeast.opus" -- "dro_dr3_despair/Absolute Hope Birthday.opus" -- "dro_dr3_despair/Beautiful Afternoon.opus" -- "dro_dr3_despair/Bli Bli Buchibuchi.opus" -- "dro_dr3_despair/DANGAN BROTHERS.opus" -- "dro_dr3_despair/DANGAN FIGHTER3.opus" -- "dro_dr3_despair/Dangan Break.opus" -- "dro_dr3_despair/Dead or Despair.opus" -- "dro_dr3_despair/Discussion -DEATH PARADE-.opus" -- "dro_dr3_despair/Doomsday Machine.opus" -- "dro_dr3_despair/Goodbye to all of the future.opus" -- "dro_dr3_despair/Grave Situation.opus" -- "dro_dr3_despair/Gyaraomega.opus" -- "dro_dr3_despair/I'm Home -Hope's Peak Academy-.opus" -- "dro_dr3_despair/Invitation to Despair.opus" -- "dro_dr3_despair/It Is You I Need.opus" -- "dro_dr3_despair/Melancholy Touch.opus" -- "dro_dr3_despair/Nightmare of Hope's Peak.opus" -- "dro_dr3_despair/Seventy-Seven.opus" -- "dro_dr3_despair/Sisters Plan.opus" -- "dro_dr3_despair/Starting progress.opus" -- "dro_dr3_despair/Super Heavy Action.opus" -- "dro_dr3_despair/The housekeeper saw!.opus" -- "dro_dr3_despair/This is Mademoiselle.opus" -- "dro_dr3_despair/Together forever.opus" -- "dro_dr3_despair/Tomorrow will be a better day.opus" -- "dro_dr3_despair/Ultimate School Sentimentalist.opus" -- "dro_dr3_despair/Until the two people fall in love.opus" -- "dro_dr3_despair/Your Ulterior Motive.opus" -- "dro_dr3_despair/ZOKKON Viscera.opus" -- "dro_dr3_future/An Unknown Despair.opus" -- "dro_dr3_future/BAD END.opus" -- "dro_dr3_future/Beginning of the End.opus" -- "dro_dr3_future/Challenge to the Future.opus" -- "dro_dr3_future/Coincidental Bad.opus" -- "dro_dr3_future/Current Despair.opus" -- "dro_dr3_future/D World.opus" -- "dro_dr3_future/DANGANRONPA FUTURE MIX.opus" -- "dro_dr3_future/DEAD OR LIE.opus" -- "dro_dr3_future/Dangan in the Black.opus" -- "dro_dr3_future/Dead One.opus" -- "dro_dr3_future/Despair Imitation.opus" -- "dro_dr3_future/Face Off.opus" -- "dro_dr3_future/High Speed Action.opus" -- "dro_dr3_future/Monokuma Hunter.opus" -- "dro_dr3_future/NG!.opus" -- "dro_dr3_future/Non Stop Action.opus" -- "dro_dr3_future/Old World Order.opus" -- "dro_dr3_future/Please Give Me Wings.opus" -- "dro_dr3_future/Puzzle to the Truth.opus" -- "dro_dr3_future/Recall-THE END.opus" -- "dro_dr3_future/Steric Confrontation.opus" -- "dro_dr3_future/Suicide.opus" -- "dro_dr3_future/TIME LIMIT!.opus" -- "dro_dr3_future/The Biggest Tragic Event in Human History.opus" -- "dro_dr3_future/The mystery of where and who.opus" -- "dro_dr3_future/Walking Despair.opus" -- "dro_dr_theanimation/HOTEL Hope's Peak.opus" -- "dro_dr_theanimation/Never Say Never.opus" -- "dro_dr_theanimation/Seventy-Eight Observing Hopeful Nebulae.opus" -- "dro_dr_theanimation/Zetsubousei Hero Chiryouyaku.opus" -- "dro_drae/A Girl in front of Abnormalities.opus" -- "dro_drae/Business Trip Punishment Adult Diorama.opus" -- "dro_drae/Business Trip Punishment Helicopter Crash.opus" -- "dro_drae/Business Trip Punishment Monokuma Raid.opus" -- "dro_drae/Business Trip Punishment Parachute Landing.opus" -- "dro_drae/Business Trip Punishment Swimming Explosion.opus" -- "dro_drae/Confrontations.opus" -- "dro_drae/DSO Beautiful Dead -With Intro-.opus" -- "dro_drae/DSO Beautiful Dead.opus" -- "dro_drae/DSO Danganronpa.opus" -- "dro_drae/DSO Despair Syndrome (2).opus" -- "dro_drae/DSO Despair Syndrome.opus" -- "dro_drae/DSO Distrust.opus" -- "dro_drae/DSO Hope vs Despair.opus" -- "dro_drae/DSO Living to the fullest.opus" -- "dro_drae/DSO New World Order.opus" -- "dro_drae/DSO Welcome to Despair School.opus" -- "dro_drae/Danger Area.opus" -- "dro_drae/Execution.opus" -- "dro_drae/Genocider Returns.opus" -- "dro_drae/Genocider Unleashed [No Intro].opus" -- "dro_drae/Genocider Unleashed.opus" -- "dro_drae/Goodbye Despair City.opus" -- "dro_drae/Hey Yeah!.opus" -- "dro_drae/It's going further.opus" -- "dro_drae/Killing Moments.opus" -- "dro_drae/Monoc-Man.opus" -- "dro_drae/Monokuma Factory.opus" -- "dro_drae/Progressive [Short Version].opus" -- "dro_drae/Progressive.opus" -- "dro_drae/Punishment of the Hero.opus" -- "dro_drae/Punishment of the Mage.opus" -- "dro_drae/Punishment of the Priest.opus" -- "dro_drae/Punishment of the Sage.opus" -- "dro_drae/Punishment of the Warrior.opus" -- "dro_drae/Robots wanna Fight.opus" -- "dro_drae/Secret Base.opus" -- "dro_drae/Silence in Despair.opus" -- "dro_drae/Sing along the Monokuma Song [Instrumental].opus" -- "dro_drae/Sing along the Monokuma Song.opus" -- "dro_drae/Sinister Path.opus" -- "dro_drae/So Listen....opus" -- "dro_drae/Solve Mysteries.opus" -- "dro_drae/This is Kurokuma.opus" -- "dro_drae/Toko's Dellusions.opus" -- "dro_drae/Train Station Panic.opus" -- "dro_drae/VERSUS Round 2.opus" -- "dro_drae/VERSUS.opus" -- "dro_drae/Warriors of Hope Battle.opus" -- "dro_drae/Warriors of Hope.opus" -- "dro_drae/Welcome to Towa Tower.opus" -- "dro_drae/Wonderful Dead - 001.opus" -- "dro_drae/Wonderful Dead - 002.opus" -- "dro_drae/Wonderful Dead - 003.opus" -- "dro_drae/Wonderful Dead - 004.opus" -- "dro_drae/Wonderful Dead - 005.opus" -- "dro_drf/All All Apologies Remix.opus" -- "dro_drf/Beautiful Confinement.opus" -- "dro_drf/Beautiful Lie Remix.opus" -- "dro_drf/Beautiful Snowfall.opus" -- "dro_drf/Intellect of the Ultimates.opus" -- "dro_drf/Jung.opus" -- "dro_drf/The Darkest of Nights.opus" -- "dro_drf/Ultimate Manipulation.opus" -- "dro_drv3/Almost Hell Heaven.opus" -- "dro_drv3/Battle with Despair.opus" -- "dro_drv3/Battle with Final Despair.opus" -- "dro_drv3/Battle with True Despair.opus" -- "dro_drv3/Beautiful Lie in ProgramWorld.opus" -- "dro_drv3/Beautiful Lie.opus" -- "dro_drv3/Becoming Friends.opus" -- "dro_drv3/Body Discovery A.opus" -- "dro_drv3/Body Discovery B.opus" -- "dro_drv3/Body Discovery C.opus" -- "dro_drv3/Body Discovery D.opus" -- "dro_drv3/Body Discovery E.opus" -- "dro_drv3/Brain Drive.opus" -- "dro_drv3/Bugs Panic.opus" -- "dro_drv3/Cave Ordeal.opus" -- "dro_drv3/Clair de Lune from Suite bergamasque.opus" -- "dro_drv3/Class Trial (Cosmic Edition).opus" -- "dro_drv3/Class Trial (Dawn Edition) - Volume Three.opus" -- "dro_drv3/Class Trial (Revival Edition) [With Intro].opus" -- "dro_drv3/Class Trial (Revival Edition).opus" -- "dro_drv3/Class Trial (Solar Edition) - Volume Three [With Intro].opus" -- "dro_drv3/Class Trial (Solar Edition) - Volume Three.opus" -- "dro_drv3/Climactic Reasoning V3.opus" -- "dro_drv3/Climactic Return V3.opus" -- "dro_drv3/Cool Morning.opus" -- "dro_drv3/DANGANRONPA V3 [Loop].opus" -- "dro_drv3/DANGANRONPA V3.opus" -- "dro_drv3/DANVEGAS.opus" -- "dro_drv3/DRV3 Title Theme.opus" -- "dro_drv3/DX Training Plan.opus" -- "dro_drv3/Dangan Training Plan.opus" -- "dro_drv3/Darkness Time.opus" -- "dro_drv3/Despair Death Road.opus" -- "dro_drv3/Despair Searching in ProgramWorld.opus" -- "dro_drv3/Despair Searching.opus" -- "dro_drv3/Dig Here! Monosquirrel.opus" -- "dro_drv3/Disconnection -Break-.opus" -- "dro_drv3/Discussion -BREAK- [3rd mix].opus" -- "dro_drv3/Discussion -HEAT UP- [3rd mix].opus" -- "dro_drv3/Discussion -HOPE VS DESPAIR- [3rd mix].opus" -- "dro_drv3/Discussion -PANIC-.opus" -- "dro_drv3/Discussion -PERJURY-.opus" -- "dro_drv3/Discussion -SCRUM- [With Intro].opus" -- "dro_drv3/Discussion -SCRUM-.opus" -- "dro_drv3/EXISAL TRIBE.opus" -- "dro_drv3/Everyone's Killing Graduation Ceremony.opus" -- "dro_drv3/Excavation Imagination.opus" -- "dro_drv3/FINAL T.A..opus" -- "dro_drv3/Finding Peace Party.opus" -- "dro_drv3/Good Bearning!.opus" -- "dro_drv3/HYPER FINAL T.A..opus" -- "dro_drv3/HYPER NEW T.A. Arrange A.opus" -- "dro_drv3/HYPER NEW T.A. Arrange B.opus" -- "dro_drv3/HYPER NEW T.A..opus" -- "dro_drv3/HYPER ULTRA FINAL T.A..opus" -- "dro_drv3/HYPER ULTRA NEW T.A. Arrange A.opus" -- "dro_drv3/HYPER ULTRA NEW T.A. Arrange B.opus" -- "dro_drv3/HYPER ULTRA NEW T.A..opus" -- "dro_drv3/Heartless Journey.opus" -- "dro_drv3/Hope Death Road.opus" -- "dro_drv3/Hope Searching.opus" -- "dro_drv3/Hope of Autumn.opus" -- "dro_drv3/Hope of Spring.opus" -- "dro_drv3/Hope of Summer.opus" -- "dro_drv3/Hope of Winter.opus" -- "dro_drv3/Island Ordeal.opus" -- "dro_drv3/Kagonoko.opus" -- "dro_drv3/Let's Get Killing Broadcast.opus" -- "dro_drv3/Let's Get Killing.opus" -- "dro_drv3/Living in Lazy Parallel World [With Intro].opus" -- "dro_drv3/Living in Lazy Parallel World.opus" -- "dro_drv3/Memory Flashlight.opus" -- "dro_drv3/Monomono Slots.opus" -- "dro_drv3/Moon on the Water.opus" -- "dro_drv3/Mr. Monokuma's Extracurricular Lesson V3.opus" -- "dro_drv3/Mr. Monokuma's Lesson V3.opus" -- "dro_drv3/Music Gallery v3.opus" -- "dro_drv3/NEW ANAGRAM.opus" -- "dro_drv3/NEW T.A. Arrange A.opus" -- "dro_drv3/NEW T.A. Arrange B.opus" -- "dro_drv3/NEW T.A..opus" -- "dro_drv3/New Classmate of the Dead.opus" -- "dro_drv3/New World Order V3.opus" -- "dro_drv3/Nightmare in the Locker.opus" -- "dro_drv3/OUTLAW RUNNER.opus" -- "dro_drv3/Objection -CROSS SWORD- V3.opus" -- "dro_drv3/REAL-FICTION.opus" -- "dro_drv3/Rise of the Ultimate.opus" -- "dro_drv3/SAKE NO TUKAMIDORI.opus" -- "dro_drv3/School Ordeal.opus" -- "dro_drv3/Season of Despair.opus" -- "dro_drv3/Sing of a Hollow Truth.opus" -- "dro_drv3/Spirit, Sentiment and Splendor.opus" -- "dro_drv3/Sudden Beautiful Lie.opus" -- "dro_drv3/Talent Select.opus" -- "dro_drv3/The Consequences of Truths and Lies.opus" -- "dro_drv3/The Destruction of the Gifted Inmates Academy.opus" -- "dro_drv3/The End of DNG.opus" -- "dro_drv3/The Flea Waltz.opus" -- "dro_drv3/The Monocubs Arrive.opus" -- "dro_drv3/The New Space Journey.opus" -- "dro_drv3/The Picture Scroll of Executions Past and Present.opus" -- "dro_drv3/The Thread of Anguish.opus" -- "dro_drv3/The Trial of Monokuma.opus" -- "dro_drv3/Transformations.opus" -- "dro_drv3/Trial END.opus" -- "dro_drv3/Trial Prepare.opus" -- "dro_drv3/Voting Slots.opus" -- "dro_drv3/Wonderful Story.opus" -- "dro_extra/4 Minutes Before Death Variation.opus" -- "dro_extra/4 Minutes Before Death.opus" -- "dro_extra/Afterschool Hours.opus" -- "dro_extra/Also Sprach Brooks (Smooth Jazz ver).opus" -- "dro_extra/Also Sprach Brooks.opus" -- "dro_extra/Another Ace Investigation.opus" -- "dro_extra/Assailant.opus" -- "dro_extra/BOX 16 (Ferdk Remix).opus" -- "dro_extra/Beautiful Dead (Family Jules Remix).opus" -- "dro_extra/Beautiful Dead (ZoomSpace Remix).opus" -- "dro_extra/Beautiful Lie (Everything Fantasy Remix).opus" -- "dro_extra/Beautiful Lie (In The Game).opus" -- "dro_extra/Beautiful Sting.opus" -- "dro_extra/Biosphere.opus" -- "dro_extra/Caduceus USA N.B.opus" -- "dro_extra/Chillaxing Lie.opus" -- "dro_extra/Clapping.opus" -- "dro_extra/Class Trial (Lunar Edition).opus" -- "dro_extra/Code Blue Stigma.opus" -- "dro_extra/Code Blue.opus" -- "dro_extra/Despair Searching (Everything Fantasy Remix).opus" -- "dro_extra/Dining Bar F.opus" -- "dro_extra/Eerie.opus" -- "dro_extra/Ekoroshia (ferdk remix).opus" -- "dro_extra/Eternal Blue.opus" -- "dro_extra/Exploration.opus" -- "dro_extra/Ghost Trick Main Theme.opus" -- "dro_extra/HYPER NEW T.A. (Kanku Remix).opus" -- "dro_extra/HYPER ULTRA NEW T.A. (Kanku Remix).opus" -- "dro_extra/Human community.opus" -- "dro_extra/Ikoroshia (Everything Fantasy Remix).opus" -- "dro_extra/It's Kill Or Be Killed!.opus" -- "dro_extra/Its a Wonderful World.opus" -- "dro_extra/LBMR Investigation.opus" -- "dro_extra/LBMR Opening.opus" -- "dro_extra/Lamb Game between Male and Female.opus" -- "dro_extra/Living in Lazy Parallel World (Dacian Grada Remix).opus" -- "dro_extra/Logic Searching.opus" -- "dro_extra/Mi Oh Daa.opus" -- "dro_extra/Mischievous Days.opus" -- "dro_extra/Mr. Monokuma's Lesson (Stevie Viola Remix).opus" -- "dro_extra/Music Factory.opus" -- "dro_extra/NEW T.A. (Kanku Remix).opus" -- "dro_extra/Nathan Is Our Boss.opus" -- "dro_extra/Pleather for Breakfast.opus" -- "dro_extra/Quaerere mortuorum.opus" -- "dro_extra/Refreshing Morning.opus" -- "dro_extra/SAFETY FIRST.B.opus" -- "dro_extra/Scenery in the Blue.opus" -- "dro_extra/Self affirmation.opus" -- "dro_extra/Sins of the Island.opus" -- "dro_extra/So Happy World.opus" -- "dro_extra/Sonic CD (US) Stage Clear Music.opus" -- "dro_extra/Spirit, Sentiment and Splendor (Dacian Grada Remix).opus" -- "dro_extra/Suspense of the Dead II.opus" -- "dro_extra/Suspense of the Dead.opus" -- "dro_extra/Tension.opus" -- "dro_extra/Tinderbox.opus" -- "dro_extra/Waiting for the Evening.opus" -- "dro_extra/Wildstyle.opus" -- "dro_extra/Wonderful Dawn In ProgramWorld.opus" -- "dro_extra/Wonderful Dawn.opus" -- "dro_jerm/Cool Morning (In The Game).opus" -- "dro_jerm/Le Arrangment de Clair de Lune from Suite Bergamasque.opus" -- "dro_jerm/Lonely Cold Distrust.opus" -- "dro_jerm/Murder on the Roulette Floor.opus" -- "dro_killer7/Angel's Despair.opus" -- "dro_killer7/Blackburn.opus" -- "dro_killer7/Cuisine.opus" -- "dro_killer7/Postgasse.opus" -- "dro_killer7/Rave On.opus" -- "dro_killer7/Reloaded.opus" -- "dro_killer7/Resound of Silence.opus" -- "dro_killer7/Russian Roulette.opus" -- "dro_killer7/Setting Sun.opus" -- "dro_killer7/Shoot Speed.opus" -- "dro_killer7/Sweet Blue Flag.opus" -- "dro_killer7/Visionary Community.opus" -- "dro_killer7/Where Angels Play.opus" -- "dro_persona/Beneath the Mask.opus" -- "dro_persona/Changing of the Seasons.opus" -- "dro_persona/Climax Theater.opus" -- "dro_persona/Doubt.opus" -- "dro_persona/Escape.opus" -- "dro_persona/Heartbeat, Heartbreak.opus" -- "dro_persona/Last Surprise.opus" -- "dro_persona/Mistic.opus" -- "dro_persona/Operation -BABE HUNT-.opus" -- "dro_persona/Paulownia Mall - Instrumental -.opus" -- "dro_persona/Paulownia Mall.opus" -- "dro_persona/School Days.opus" -- "dro_persona/This Mysterious Feeling.opus" -- "dro_persona/Tokyo Daylight.opus" -- "dro_persona/Tokyo Emergency.opus" -- "dro_persona/Very Strange Feeling.opus" -- "dro_persona/Your Affection.opus" -- "dro_sdra2/1st island.opus" -- "dro_sdra2/2nd island.opus" -- "dro_sdra2/3rd island.opus" -- "dro_sdra2/4th island.opus" -- "dro_sdra2/5th Island Hotel.opus" -- "dro_sdra2/5th Island.opus" -- "dro_sdra2/6th island.opus" -- "dro_sdra2/CRITICAL ERROR.opus" -- "dro_sdra2/Class Trial (Another Edition).opus" -- "dro_sdra2/Class Trial -REFRESH-.opus" -- "dro_sdra2/Class Trial A Fearful Encounter.opus" -- "dro_sdra2/Deep Night.opus" -- "dro_sdra2/Investigation.opus" -- "dro_sdra2/Melody Rhythm.opus" -- "dro_sdra2/Monocrow's Theme.opus" -- "dro_sdra2/Monocruise.opus" -- "dro_sdra2/Nocturne of Amestris Duet.opus" -- "dro_sdra2/Real World.opus" -- "dro_sdra2/Remind Satsuki.opus" -- "dro_sdra2/Sannoji Trap.opus" -- "dro_sdra2/Shoubai Shop.opus" -- "dro_vetrom/BOX 15 (Vetrom Remix).opus" -- "dro_vetrom/Beautiful Days (Vetrom Remix).opus" -- "dro_vetrom/Class Trial (Revival Edition) (Vetrom Remix).opus" -- "dro_vetrom/Class Trial (Solar Edition) (Vetrom Remix).opus" -- "dro_vetrom/DIVE DRIVE (Vetrom Remix).opus" -- "dro_vetrom/Discussion -PANIC- (Vetrom Remix).opus" -- "dro_vetrom/Discussion -SCRUM- (Vetrom Remix).opus" -- "dro_vetrom/Hope Searching (Vetrom Remix).opus" -- "dro_vetrom/Ms. Monomi's Practice Lesson (Vetrom Remix).opus" -- "dro_vetrom/New World Order (Vetrom Remix).opus" -- "dro_vetrom/Objection -CROSS SWORD- (Vetrom Remix).opus" -- "dro_vetrom/To Survive (Vetrom Remix).opus" -- "dro_vetrom/Trial Underground (Vetrom Remix).opus" -- "dro_ze999/9hours, 9persons, 9doors.opus" -- "dro_ze999/9years.opus" -- "dro_ze999/Binary Game.opus" -- "dro_ze999/Chill and Rigor.opus" -- "dro_ze999/Digital Root.opus" -- "dro_ze999/Eternitybox.opus" -- "dro_ze999/Extreme Extrication.opus" -- "dro_ze999/Foreboding.opus" -- "dro_ze999/Imaginary.opus" -- "dro_ze999/Morphogenetic Sorrow.opus" -- "dro_ze999/Nonary Game.opus" -- "dro_ze999/Octary Game.opus" -- "dro_ze999/Quaternary Game.opus" -- "dro_ze999/Quietus.opus" -- "dro_ze999/Quinary Game.opus" -- "dro_ze999/Recollection.opus" -- "dro_ze999/Riddle and Puzzle.opus" -- "dro_ze999/Senary Game.opus" -- "dro_ze999/Septenary Game.opus" -- "dro_ze999/Ternary Game.opus" -- "dro_ze999/Tranquility.opus" -- "dro_ze999/Trepidation.opus" -- "dro_ze999/Unary Game.opus" -- "dro_ze999/Who is Zero.opus" -- "dro_zevlr/Ambidexterity.opus" -- "dro_zevlr/Annihilation.opus" -- "dro_zevlr/Biology.opus" -- "dro_zevlr/Biotope Remix.opus" -- "dro_zevlr/Biotope.opus" -- "dro_zevlr/Cabin.opus" -- "dro_zevlr/Data.opus" -- "dro_zevlr/Decompression.opus" -- "dro_zevlr/Director.opus" -- "dro_zevlr/Dispensary.opus" -- "dro_zevlr/Gaulem.opus" -- "dro_zevlr/Lounge.opus" -- "dro_zevlr/Monitor.opus" -- "dro_zevlr/Pantry.opus" -- "dro_zevlr/Q.opus" -- "dro_zevlr/Recreation.opus" -- "dro_zevlr/Treatment.opus" -- "dro_zevlr/Virtue's Last Reward.opus" diff --git a/config_sample/README.md b/config_sample/README.md index 53ff465fc..347daafcc 100644 --- a/config_sample/README.md +++ b/config_sample/README.md @@ -1,23 +1,19 @@ # Server configuration files ## Before you get started... -With your copy of this software, you should check that you have the following files in `\config_sample\`: +With your copy of this software, you should check that you have the following files in `/config_sample/`: * `config.yaml` -* `area_lists.yaml` -* `area_templates.yaml` * `areas.yaml` * `backgrounds.yaml` * `gimp.yaml` -* `iniswaps.yaml` * `music.yaml` -* `music_lists.yaml` As well as these folders: * `area_lists` -* `background_lists` -* `character_lists` +* `bg_lists` +* `char_lists` * `music_lists` Make sure you have all of the above inside this folder (otherwise, the server may hang or crash while starting). @@ -37,13 +33,6 @@ Each file contains an example configuration. Further instructions should be loca * **config.yaml** - Contains server configuration attributes such as server name, player limit, the port it should use to listen to connections, whether it should be advertised in the AO master server list, etc. -* **area_lists.yaml** - - Lists the area lists the server supports. - - This is the list that is returned when moderators use `/area_lists`. No validation is performed to check if some area list is in this list of area lists. - -* **area_templates.yaml** - - Lists the area templates area lists can use to generate areas. **Currently unused.** - * **areas.yaml** - Contains the default server area list. - This is the area list that is loaded when the server starts, as well as the area list the server falls back to when moderators use `/area_list` on its own. @@ -55,14 +44,6 @@ Each file contains an example configuration. Further instructions should be loca * **gimp.yaml** - Lists a pool of messages. If a player is gimped, their messages will be replaced with a randomly chosen message from this list. -* **iniswaps.yaml** - - Lists the allowed iniswapping combinations. - - This is the list the server uses to check in areas that prevent iniswapping to check if they should allow a particular iniswap to exist. - * **music.yaml** - Contains the default server music list. - This is the music list that is loaded when the server starts, as well as the music list the server sends to a client that uses `/music_list` on its own. - -* **music_lists.yaml** - - Lists the music lists the server supports. - - This is the list that is returned when players use `/music_lists`. No validation is performed to check if some music list is in this list of music lists. diff --git a/config_sample/area_lists.yaml b/config_sample/area_lists.yaml deleted file mode 100644 index 3097a9229..000000000 --- a/config_sample/area_lists.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# A sample area lists document -# List your area lists as you want them output via /area_lists, one per line -# Do not include - or any other YAML indicator that indicates items. -default_aa -old \ No newline at end of file diff --git a/config_sample/area_templates.yaml b/config_sample/area_templates.yaml deleted file mode 100644 index 2b54fb2d0..000000000 --- a/config_sample/area_templates.yaml +++ /dev/null @@ -1,32 +0,0 @@ -- apply_template_ranges: true - default: 0/1, 4/6, 5/7 - RP: 3, 4, 9/15 - AO_Courtroom: 1 -- template_name: default - area: Default Area Name - background: Gym_HD - evidence_mod: FFA - bglock: false - locking_allowed: false - iniswap_allowed: true -- template_name: RP - area: Room - background: Kitchen_HD - bglock: true - evidence_mod: CM - locking_allowed: true - iniswap_allowed: true - rp_getarea_allowed: false -- template_name: AO_Courtroom - area: Courtroom No. - background: gs4 - bglock: true - evidence_mod: CM - locking_allowed: false - rp_getarea_allowed: true - rp_getareas_allowed: true -- template_name: OOC_Stuff - area: OOC - background: Principal's Room_HD - evidence_mod: HiddenCM - iniswap_allowed: true diff --git a/config_sample/areas.yaml b/config_sample/areas.yaml index 8a24936ae..d65b257f7 100644 --- a/config_sample/areas.yaml +++ b/config_sample/areas.yaml @@ -15,7 +15,7 @@ # *background_tod: Backgrounds per time of day. # *bglock: If false, only moderators can lock the area background; if true, no one can lock it. # *bullet: If false, only GM+ can send IC messages with bullets/shouts. If true, no such restriction is imposed. -# *cbg_allowed: If false, only GM+ can set the area's background to one not in the server's list. If true, no such restriction is imposed. +# *cbg_allowed: If false, only GM+ can set the area's background to one not in the hub's list. If true, no such restriction is imposed. # *change_reachability_allowed: If false, only GM+ may /unilock or /bilock passages affecting this area. If true, no such restriction is imposed. # *default_description: Output of /look in the area if no custom description is in place via /look_set # *evidence_mod: Unused (do not include) @@ -29,8 +29,8 @@ # *reachable_areas: If , a passage will be created from this area to every other area. If it is a comma-separated list of areas (by name), a passage will be created from this area only to the listed areas. # *restricted_chars: If a list of folder names, it prevents non-GMs using said characters from joining the area, and prompts them to choose another one if they are kicked to the area, they attempt to switch to that character while in the area, or their character is marked as restricted while they are in the area. # *rollp_allowed: If false, only GM+ may /rollp. If true, everyone can /rollp. -# *rp_getarea_allowed: If false, if the server is in RP mode, only GM+ can /getarea and similar. If true, no such restriction is imposed. -# *rp_getareas_allowed: If false, if the server is in RP mode, only GM+ can /getareas and similar. If true, no such restriction is imposed. +# *rp_getarea_allowed: If false, only GM+ can /getarea and similar. If true, no such restriction is imposed. +# *rp_getareas_allowed: If false, only GM+ can /getareas and similar. If true, no such restriction is imposed. # *scream_range: If , all areas can receive a /scream message sent from this area. If , all areas defined in reachable_areas can receive a /scream message sent from this area. If it is a comma-separated list of areas (by name), only said areas can receive /scream messages. # *song_switch_allowed: If false, only GM+ can do /play. If true, no such restriction is imposed. # *visible_areas: If , a passage will be set as visible (but not necessarily reachable) from this area to every other area. If , the procedure will be done to exactly all areas described in the areas' `reachable_areas` key. If it is a comma-separated list of areas (by name), the procedure will be done to the listed areas. diff --git a/config_sample/background_lists/.gitkeep b/config_sample/bg_lists/.gitkeep similarity index 100% rename from config_sample/background_lists/.gitkeep rename to config_sample/bg_lists/.gitkeep diff --git a/config_sample/character_lists/.gitkeep b/config_sample/char_lists/.gitkeep similarity index 100% rename from config_sample/character_lists/.gitkeep rename to config_sample/char_lists/.gitkeep diff --git a/config_sample/config.yaml b/config_sample/config.yaml index e18f2632c..f75c8b9bd 100644 --- a/config_sample/config.yaml +++ b/config_sample/config.yaml @@ -69,7 +69,6 @@ def_modifier: '' # RP configurations # announce_areas: list all areas of the server in OOC when the player joins -# rp_mode_enabled: if RP mode should be enabled (restricts certain commands) # blackout_background: background to display if lights are turned off/player is blinded # default_area_description: description to show with /look if no description is set # party_lights_timeout: time a party needs to be in an area with no lights to automatically be ended @@ -78,7 +77,6 @@ def_modifier: '' # spectator_name: name spectators get in /getarea(s) announce_areas: true -rp_mode_enabled: true blackout_background: Blackout_HD default_area_description: No description. party_lights_timeout: 10 @@ -95,9 +93,9 @@ music_change_floodguard: interval_length: 20 mute_length: 10 -# Currently unused -# Changing them will do nothing +# For debugging debug: false -guardpass: Guard -help_msg: TODO + +# Currently unused +# Changing them will do nothing diff --git a/config_sample/iniswaps.yaml b/config_sample/iniswaps.yaml deleted file mode 100644 index 3a059bb9d..000000000 --- a/config_sample/iniswaps.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# A sample allowed iniswaps document -# Allowed iniswaps for a player are only enforced for them if their area restricts iniswaps. -# List mutually permissible swaps all as part of the same item - -# For example, if your iniswap document is just this -# - -# - Phantom_HD -# - Spam_HD -# - Persona1 -# - -# - Persona2 -# - Eggs_HD -# These are the only permisible iniswaps in an area that restricts iniswaps -# -# Original Character | Iniswapped to Character -# -------------------------------------------- -# Phantom_HD | Spam_HD -# Phantom_HD | Persona1 -# Spam_HD | Phantom_HD -# Spam_HD | Persona1 -# Persona1 | Phantom_HD -# Persona1 | Spam_HD -# Persona2 | Eggs_HD -# Eggs_HD | Persona2 -# -# and a player whose iniswap combination is not in this list will not be allowed to talk IC. -# Indicate mutually permissible swaps all as part of the same item. - -- - - Apollo - - Apollo Leg - - ApolloDD -- - - Athena Maid W - - AthenaCykesSOJ -- - - Butz - - ButzEdgeworth \ No newline at end of file diff --git a/config_sample/music_lists.yaml b/config_sample/music_lists.yaml deleted file mode 100644 index a1ab0fb25..000000000 --- a/config_sample/music_lists.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# A sample music lists document -# List your music lists as you want them output via /music_lists, one per line -# Do not include - or any other YAML indicator that indicates items. -deadly_life -dr1 -dr2 -trial \ No newline at end of file diff --git a/server/area_manager.py b/server/area_manager.py index c984562fb..e0c38d1e5 100644 --- a/server/area_manager.py +++ b/server/area_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -24,27 +25,30 @@ """ from __future__ import annotations -import typing -from typing import Any, Callable, Dict, List, Set, Tuple, Union -if typing.TYPE_CHECKING: - # Avoid circular referencing - from server.client_manager import ClientManager - from server.party_manager import PartyManager - from server.tsuserver import TsuserverDR - from server.zone_manager import ZoneManager import asyncio +import random import time +import typing + +from typing import Any, Callable, Dict, List, Set, Tuple, Union from server import logger from server.asset_manager import AssetManager from server.constants import Constants from server.evidence import EvidenceList -from server.exceptions import AreaError, MusicError, ServerError +from server.exceptions import AreaError, MusicError, ServerError, TaskError from server.subscriber import Publisher - from server.validate.areas import ValidateAreas +if typing.TYPE_CHECKING: + # Avoid circular referencing + from server.hub_manager import _Hub + from server.client_manager import ClientManager + from server.party_manager import PartyManager + from server.tsuserver import TsuserverDR + from server.zone_manager import ZoneManager + class AreaManager(AssetManager): """ @@ -55,26 +59,35 @@ class AreaManager(AssetManager): class Area: """ - Create a new area for the server. + Create a new area for the hub. """ - def __init__(self, area_id: int, server: TsuserverDR, parameters: Dict[str, Any]): + def __init__( + self, + server: TsuserverDR, + hub: _Hub, + area_id: int, + parameters: Dict[str, Any] + ): """ Parameters ---------- + server : TsuserverDR + The server this area belongs to. + hub: _Hub + The hub this area belongs to. area_id: int The area ID. - server: server.TsuserverDR - The server this area belongs to. parameters: dict Area parameters as specified in the loaded area list. """ - self._clients = set() - self.id = area_id self.server = server + self.hub = hub + self.id = area_id self.publisher = Publisher(self) + self._clients = set() self.invite_list = {} self.music_looper = None self.music_looper_pargs = {} @@ -82,7 +95,6 @@ def __init__(self, area_id: int, server: TsuserverDR, parameters: Dict[str, Any] self.hp_def = 10 self.hp_pro = 10 self.doc = 'No document.' - self.status = 'IDLE' self.judgelog = [] self.shoutlog = [] self.current_music = '' @@ -90,10 +102,8 @@ def __init__(self, area_id: int, server: TsuserverDR, parameters: Dict[str, Any] self.current_music_source = '' self.evi_list = EvidenceList() self.recorded_messages = [] - self.owned = False self.ic_lock = False self.is_locked = False - self.is_gmlocked = False self.is_modlocked = False self.bleeds_to = set() self.blood_smeared = False @@ -104,6 +114,8 @@ def __init__(self, area_id: int, server: TsuserverDR, parameters: Dict[str, Any] self.lurk_length = 0 self._in_zone = None self.noteworthy = False + self.default_noteworthy_text = '[Something catches your attention]' + self.noteworthy_text = self.default_noteworthy_text self.ambient = '' self.name = parameters['area'] @@ -141,12 +153,6 @@ def __init__(self, area_id: int, server: TsuserverDR, parameters: Dict[str, Any] self.reachable_areas.add(self.name) # Area can always reach itself - def background_backup(self) -> str: - Constants.warn_deprecated('area.background_backup', - 'area.background', - '4.4') - return self.background - @property def clients(self) -> Set[ClientManager.Client]: """ @@ -156,9 +162,9 @@ def clients(self) -> Set[ClientManager.Client]: return self._clients @clients.setter - def clients(self, new_clients) -> Set[ClientManager.Client]: + def clients(self, new_clients: Set[ClientManager.Client]): """ - Set the clients parameter to the given one. + Set the clients parameter to a copy of the given one. Parameters ---------- @@ -305,10 +311,10 @@ def change_background(self, bg: str, validate: bool = True, override_blind: bool Raises ------ AreaError - If the server attempted to validate the background name and failed. + If the hub attempted to validate the background name and failed. """ - if validate and not self.server.background_manager.is_background(bg): + if validate and not self.hub.background_manager.is_background(bg): raise AreaError('Invalid background name.') self.background = bg @@ -337,18 +343,18 @@ def change_background_tod(self, bg: str, tod: str, validate: bool = True, Whether to first determine if background name is listed as a server background before changing. Defaults to True. override_blind: bool, optional - Whether to send the intended background to blind people as opposed to the server + Whether to send the intended background to blind people as opposed to the hub blackout one. Defaults to False (send blackout). Raises ------ AreaError - If the background name is non-empty and the server attempted to validate the + If the background name is non-empty and the hub attempted to validate the background name and failed, or if the background name is empty and the area already has no backgroudn associated with the given period. """ - if validate and not self.server.background_manager.is_background(bg): + if validate and not self.hub.background_manager.is_background(bg): raise AreaError('Invalid background name.') if tod not in self.background_tod and not bg: @@ -391,9 +397,9 @@ def get_chars_unusable(self, allow_restricted: bool = False, if more_unavail_chars is None: more_unavail_chars = set() - unavailable = {x.char_id for x in self.clients if x.has_character()} + unavailable = {x.char_id for x in self.clients if x.has_participant_character()} unavailable |= more_unavail_chars - restricted = {self.server.character_manager.get_character_id_by_name(name) + restricted = {self.hub.character_manager.get_character_id_by_name(name) for name in self.restricted_chars} if not allow_restricted: @@ -428,13 +434,13 @@ def get_rand_avail_char_id(self, allow_restricted: bool = False, unusable = self.get_chars_unusable(allow_restricted=allow_restricted, more_unavail_chars=more_unavail_chars) - available = {i for i in range(len(self.server.character_manager.get_characters())) + available = {i for i in range(len(self.hub.character_manager.get_characters())) if i not in unusable} if not available: raise AreaError('No available characters.') - return self.server.random.choice(tuple(available)) + return random.choice(tuple(available)) def is_char_available(self, char_id: Union[int, None], allow_restricted: bool = False, more_unavail_chars: Set[int] = None) -> bool: @@ -459,12 +465,12 @@ def is_char_available(self, char_id: Union[int, None], allow_restricted: bool = screen), or is not found to be among the area's unusable characters. """ - if char_id is None or char_id < 0: + if not self.hub.character_manager.is_char_id_participant(char_id): return True - unused = char_id in self.get_chars_unusable(allow_restricted=allow_restricted, - more_unavail_chars=more_unavail_chars) - return not unused + unusable = self.get_chars_unusable(allow_restricted=allow_restricted, + more_unavail_chars=more_unavail_chars) + return char_id not in unusable def add_to_dicelog(self, client: ClientManager.Client, msg: str): """ @@ -501,13 +507,14 @@ def get_dicelog(self) -> str: def change_doc(self, doc: str = 'No document.'): """ - Changes the casing document of the area, usually a URL. + Changes the RP document of the area, usually a URL. Parameters ---------- doc: str, optional - New casing document of the area. Defaults to 'No document.' + New RP document of the area. Defaults to 'No document.' """ + self.doc = doc def get_evidence_list(self, client: ClientManager.Client): @@ -586,8 +593,7 @@ def is_iniswap(self, client: ClientManager.Client, anim1: str, anim2: str, ------- bool True if either anim1 or anim2 point to an external location through '../../' or - their claimed character folder does not match the expected server name and the - performed iniswap is not in the list of allowed iniswaps by the server. + their claimed character folder does not match the expected server name. """ if char == client.get_char_name(): @@ -595,9 +601,6 @@ def is_iniswap(self, client: ClientManager.Client, anim1: str, anim2: str, if '..' in anim1 or '..' in anim2: return True - for char_link in self.server.allowed_iniswaps: - if client.get_char_name() in char_link and char in char_link: - return False return True def add_to_judgelog(self, client: ClientManager.Client, msg: str): @@ -677,9 +680,11 @@ def change_lights(self, new_lights: bool, initiator: ClientManager.Client = None initiator.send_ooc('You feel a light switch was flipped.') initiator.send_ooc_others('The lights were turned {}.'.format(status[new_lights]), - is_zstaff_flex=False, in_area=area if area else True, to_blind=False) - initiator.send_ooc_others('You hear a flicker.', is_zstaff_flex=False, in_area=area if area else True, - to_blind=True, to_deaf=False) + is_zstaff_flex=False, to_blind=False, + in_area=area if area else True) + initiator.send_ooc_others('You hear a flicker.', + is_zstaff_flex=False, to_blind=True, to_deaf=False, + in_area=area if area else True) initiator.send_ooc_others('(X) {} [{}] turned the lights {}.' .format(initiator.displayname, initiator.id, status[new_lights]), @@ -740,7 +745,7 @@ def play_track(self, name: str, client: ClientManager.Client, effect : int, optional Accompanying effect to the track (only used by AO 2.8.4+). Defaults to 0. raise_if_not_found : bool, optional - If True, it will raise ServerError if the track name is not in the server's music + If True, it will raise ServerError if the track name is not in the hub's music list nor the client's music list. If False, it will not care about it. Defaults to False. reveal_sneaked : bool, optional @@ -760,7 +765,7 @@ def play_track(self, name: str, client: ClientManager.Client, ServerError.FileInvalidNameError: If `name` references parent or current directories (e.g. "../hi.opus") MusicError.MusicNotFoundError: - If `name` is not a music track in the server or client's music list and + If `name` is not a music track in the hub's music list and `raise_if_not_found` is True. """ @@ -775,7 +780,7 @@ def play_track(self, name: str, client: ClientManager.Client, except MusicError.MusicNotFoundError: if raise_if_not_found: raise - name, length, source = name, -1, '' + length, source = -1, '' if 'name' not in pargs: pargs['name'] = name @@ -823,7 +828,7 @@ def loop(zeroth_loop): client.change_visibility(True) client.send_ooc_others('(X) {} [{}] revealed themselves by playing music ({}).' .format(client.displayname, client.id, client.area.id), - is_zstaff=True) + is_zstaff_flex=True) def play_current_track(self, only_for: Set[ClientManager.Client] = None, force_same_restart: int = -1): @@ -916,28 +921,6 @@ def get_shoutlog(self) -> str: info += '\r\n*{}'.format(log) return info - def change_status(self, value: str): - """ - Change the casing status of the area to one of predetermined values. - - Parameters - ---------- - value: str - New casing status of the area. - - Raises - ------ - AreaError - If the new casing status is not among the allowed values. - """ - - allowed_values = ['idle', 'building-open', 'building-full', 'casing-open', - 'casing-full', 'recess'] - if value.lower() not in allowed_values: - raise AreaError('Invalid status. Possible values: {}' - .format(', '.join(allowed_values))) - self.status = value.upper() - def get_clock_creator(self) -> ClientManager.Client: """ Return a client that has an active day cycle involving the current area. @@ -955,13 +938,13 @@ def get_clock_creator(self) -> ClientManager.Client: If no client has an active day cycle involving the current area. """ - for client in self.server.get_clients(): + for client in self.hub.get_players(): try: - args = self.server.tasker.get_task_args(client, ['as_day_cycle']) - except KeyError: + task = self.server.task_manager.get_task(client, 'as_day_cycle') + except TaskError.TaskNotFoundError: pass else: - area_1, area_2 = args[1], args[2] + area_1, area_2 = task.parameters['area_1'], task.parameters['area_2'] if area_1 <= self.id <= area_2: return client raise AreaError.ClientNotFound @@ -983,10 +966,12 @@ def get_clock_period(self) -> str: except AreaError.ClientNotFound: return '' else: - period = self.server.tasker.get_task_attr(client, ['as_day_cycle'], 'period') + task = self.server.task_manager.get_task(client, 'as_day_cycle') + period = task.parameters['period'] return period - def get_look_output_for(self, client: ClientManager.Client) -> Tuple[bool, str, str]: + def get_look_output_for(self, + client: ClientManager.Client) -> Tuple[bool, bool, str, bool, str]: """ Return information about the visual aspect of the current area in accordance to a particular player's perspective. @@ -998,20 +983,26 @@ def get_look_output_for(self, client: ClientManager.Client) -> Tuple[bool, str, Returns ------- - Tuple[bool, str, str] + Tuple[bool, bool, str, bool, str] - First argument is True if information that only GM+ could have obtained is included in the return, False otherwise. - - Second argument is a description of the current area (ignoring whether `client` is - blind or lights are off) - - Third argument is a description of the players in the current area that `client` + - Second argument is whether a non-default description is stated in the next + argument. + - Third argument is a description of the current area (ignoring whether `client` is + blind or lights are off). + - Fourth argument is whether a non-default description is stated in the next + argument and the player does not see another player in the target area. + - Fifth argument is a description of the players in the current area that `client` is entitled to see. """ elevated = False if self.description == self.server.config['default_area_description']: + has_area_description = False area_description = 'Nothing particularly interesting.' else: + has_area_description = True area_description = self.description players = client.get_visible_clients(self) @@ -1054,11 +1045,15 @@ def get_look_output_for(self, client: ClientManager.Client) -> Tuple[bool, str, elevated = True player_description += ' (S)' - if not player_description: + if player_description and (players-{player}): + has_other_players = True + else: # This could happen for example, when a player peeks into an area where they cannot # see any player. + has_other_players = False player_description = 'no one' - return elevated, area_description, player_description + return (elevated, has_area_description, area_description, + has_other_players, player_description) def unlock(self): @@ -1066,16 +1061,6 @@ def unlock(self): Unlock the area so that non-authorized players may now join. """ - self.is_locked = False - if not self.is_gmlocked and not self.is_modlocked: - self.invite_list = {} - - def gmunlock(self): - """ - Unlock the area if it had a GM lock so that non-authorized players may now join. - """ - - self.is_gmlocked = False self.is_locked = False if not self.is_modlocked: self.invite_list = {} @@ -1086,7 +1071,6 @@ def modunlock(self): """ self.is_modlocked = False - self.is_gmlocked = False self.is_locked = False self.invite_list = {} @@ -1141,9 +1125,10 @@ def __repr__(self): The string follows the convention 'A::AreaID:AreaName:ClientsInArea' """ - return 'A::{}:{}:{}'.format(self.id, self.name, len(self.clients)) + return 'A::{}:{}:{}:{}'.format(self.id, self.name, len(self.clients), + self.hub.get_numerical_id()) - def __init__(self, server: TsuserverDR): + def __init__(self, server: TsuserverDR, hub: Union[_Hub, None] = None): """ Create an area manager object. @@ -1151,22 +1136,19 @@ def __init__(self, server: TsuserverDR): ---------- server: TsuserverDR The server this area manager belongs to. + hub : _Hub, optional + The hub this area manager belongs to. Defaults to None. """ - super().__init__(server) + super().__init__(server, hub=hub) self._areas = [] - self._source_file = 'config/areas.yaml' + self._source_file = None + self._previous_source_file = None self.area_names = set() - self.load_file(self._source_file) - @property - def areas(self) -> List[Area]: - Constants.warn_deprecated('AreaManager.areas', - 'AreaManager.get_areas()', - '4.4') - return self.get_areas() + self._default_area_id = 0 - def get_name(self) -> str: + def get_type_name(self) -> str: """ Return `'area list'`. @@ -1192,15 +1174,15 @@ def get_default_file(self) -> str: def get_loader(self) -> Callable[[str, ], str]: """ - Return `self.server.load_file`. + Return `self.hub.load_areas`. Returns ------- Callable[[str, ], str] - `self.server.load_file`. + `self.hub.load_areas`. """ - return self.server.load_areas + return self.hub.load_areas def get_source_file(self) -> Union[str, None]: """ @@ -1215,6 +1197,20 @@ def get_source_file(self) -> Union[str, None]: return self._source_file + def get_previous_source_file(self) -> Union[str, None]: + """ + Return the output that self.get_source_file() would have returned *before* the last + successful time an area list was successfully loaded. + If no such call was ever made, return None. + + Returns + ------- + Union[str, None] + Previous source file or None. + """ + + return self._previous_source_file + def get_custom_folder(self) -> str: """ Return `'config/area_lists'`. @@ -1239,12 +1235,6 @@ def get_areas(self) -> List[Area]: return self._areas.copy() - def load_areas(self, area_list_file: str = 'config/areas.yaml') -> List[Area]: - Constants.warn_deprecated('area_manager.load_areas', - 'area_manager.load_file', - '4.4') - return self.load_file(area_list_file) - def load_file(self, source_file: str) -> List: """ Load an area list from a file. @@ -1272,7 +1262,7 @@ def load_file(self, source_file: str) -> List: """ areas = ValidateAreas().validate(source_file, extra_parameters={ - 'server_character_list': self.server.character_manager.get_characters(), + 'server_character_list': self.hub.character_manager.get_characters(), 'server_default_area_description': self.server.config['default_area_description'] }) areas = self._load_areas(areas, source_file) @@ -1307,7 +1297,7 @@ def load_raw(self, yaml_contents: Dict) -> List[Area]: """ areas = ValidateAreas().validate_contents(yaml_contents, extra_parameters={ - 'server_character_list': self.server.character_manager.get_characters(), + 'server_character_list': self.hub.character_manager.get_characters(), 'server_default_area_description': self.server.config['default_area_description'] }) areas = self._load_areas(areas, None) @@ -1316,14 +1306,14 @@ def load_raw(self, yaml_contents: Dict) -> List[Area]: return areas def _load_areas(self, areas: List[Area], source_file: Union[str, None]) -> List[Area]: - self.server.old_area_list = self._source_file + self._previous_source_file = self._source_file # Now we are ready to create the areas self._source_file = source_file temp_areas = list() for (i, area_item) in enumerate(areas): - temp_areas.append(self.Area(i, self.server, area_item)) + temp_areas.append(self.Area(self.server, self.hub, i, area_item)) old_areas = self.get_areas() self._areas = temp_areas @@ -1332,22 +1322,31 @@ def _load_areas(self, areas: List[Area], source_file: Union[str, None]) -> List[ # Only once all areas have been created, actually set the corresponding values # Helps avoiding junk area lists if there was an error # But first, remove all zones - backup_zones = self.server.zone_manager.get_zones() + backup_zones = self.hub.zone_manager.get_zones() for (zone_id, zone) in backup_zones.items(): - self.server.zone_manager.delete_zone(zone_id) + self.hub.zone_manager.delete_zone(zone_id) for client in zone.get_watchers(): client.send_ooc('Your zone has been automatically deleted due to an area list ' 'load.') # And end all existing day cycles - for client in self.server.get_clients(): + clients_with_clocks: Set[ClientManager.Client] = set() + for area in old_areas: try: - client.server.tasker.remove_task(client, ['as_day_cycle']) - except KeyError: + clock_creator = area.get_clock_creator() + except AreaError.ClientNotFound: + continue + else: + clients_with_clocks.add(clock_creator) + + for client in clients_with_clocks: + try: + client.server.task_manager.delete_task(client, 'as_day_cycle') + except TaskError.TaskNotFoundError: pass # And remove all global IC and global IC prefixes - for client in self.server.get_clients(): + for client in self.hub.get_players(): if client.multi_ic: client.send_ooc('Due to an area list reload, your global IC was turned off. You ' 'may turn it on again manually.') @@ -1357,12 +1356,16 @@ def _load_areas(self, areas: List[Area], source_file: Union[str, None]) -> List[ 'You may set it again manually.') client.multi_ic_pre = '' + # If the default area ID is now past the number of available areas, reset it back to zero + if self._default_area_id >= len(self._areas): + self._default_area_id = 0 + # And do other tasks associated with areas reloading self.publisher.publish('areas_loaded', dict()) - # If the default area ID is now past the number of available areas, reset it back to zero - if self.server.default_area >= len(self._areas): - self.server.default_area = 0 + # Add new areas to hub + for area in self._areas: + self.hub.add_area(area) for area in old_areas: # Decide whether the area still exists or not @@ -1389,7 +1392,7 @@ def _load_areas(self, areas: List[Area], source_file: Union[str, None]) -> List[ message = 'Area list reload. Moving you to the new {}.' else: message = ('Area list reload. Your previous area no longer exists. Moving you ' - 'to the server default area {}.') + 'to the hub default area {}.') client.send_ooc(message.format(new_area.name)) client.change_area(new_area, ignore_checks=True, change_to=new_char_id, @@ -1409,10 +1412,29 @@ def _load_areas(self, areas: List[Area], source_file: Union[str, None]) -> List[ def default_area(self) -> AreaManager.Area: """ - Return the Area object corresponding to the server's default area. + Return the Area object corresponding to the hub's default area. """ - return self._areas[self.server.default_area] + return self._areas[self._default_area_id] + + def set_default_area(self, area: Area): + """ + Set the default area of the area manager. + + Parameters + ---------- + area : Area + New area. + + Raises + ------ + AreaError + If the manager does not recognize the area as an area it manages. + """ + + if area not in self._areas: + raise AreaError + self._default_area_id = area.id def get_area_by_name(self, name: str) -> AreaManager.Area: """ @@ -1491,12 +1513,16 @@ def get_client_view(self, client: ClientManager.Client, from_area: Area) -> List need_to_check = from_area is None or client.is_staff() or client.is_transient # Now add areas - prepared_area_list = list() + if from_area is None: + from_area = client.area + + prepared_list = list() + prepared_list.append(Constants.get_first_area_list_item('HUB', from_area.hub, from_area)) for area in self.get_areas(): if need_to_check or area.name in from_area.visible_areas: - prepared_area_list.append("{}-{}".format(area.id, area.name)) + prepared_list.append(f'{area.id}-{area.name}') - return prepared_area_list + return prepared_list def change_passage_lock(self, client: ClientManager.Client, areas: List[AreaManager.Area], diff --git a/server/area_manager_revamp.py b/server/area_manager_revamp.py deleted file mode 100644 index aea8c995b..000000000 --- a/server/area_manager_revamp.py +++ /dev/null @@ -1,310 +0,0 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server -# -# Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee -# -# 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 3 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, see . - -import asyncio -import random - -import time -import yaml - -from server.exceptions import AreaError -from server.evidence import EvidenceList - - -class AreaManager: - class Area: - def __init__(self, area_id, server, name, background, bg_lock, evidence_mod = 'FFA', - locking_allowed = False, iniswap_allowed = True, rp_getarea_allowed = True, rp_getareas_allowed = True): - self.iniswap_allowed = iniswap_allowed - self.clients = set() - self.invite_list = {} - self.id = area_id - self.name = name - self.background = background - self.bg_lock = bg_lock - self.server = server - self.music_looper = None - self.next_message_time = 0 - self.hp_def = 10 - self.hp_pro = 10 - self.doc = 'No document.' - self.status = 'IDLE' - self.judgelog = [] - self.current_music = '' - self.current_music_player = '' - self.evi_list = EvidenceList() - self.is_recording = False - self.recorded_messages = [] - self.evidence_mod = evidence_mod - self.locking_allowed = locking_allowed - #New lines - self.rp_getarea_allowed = rp_getarea_allowed - self.rp_getareas_allowed = rp_getareas_allowed - self.owned = False - - """ - #debug - self.evidence_list.append(Evidence("WOW", "desc", "1.png")) - self.evidence_list.append(Evidence("wewz", "desc2", "2.png")) - self.evidence_list.append(Evidence("weeeeeew", "desc3", "3.png")) - """ - - self.is_locked = False - self.is_gmlocked = False - self.is_modlocked = False - - def new_client(self, client): - self.clients.add(client) - - def remove_client(self, client): - self.clients.remove(client) - if len(self.clients) == 0: - self.unlock() - if client.is_cm: - client.is_cm = False - self.owned = False - if self.is_locked: - self.unlock() - - def unlock(self): - self.is_locked = False - if not self.is_gmlocked and not self.is_modlocked: - self.invite_list = {} - - def gmunlock(self): - self.is_gmlocked = False - self.is_locked = False - if not self.is_modlocked: - self.invite_list = {} - - def modunlock(self): - self.is_modlocked = False - self.is_gmlocked = False - self.is_locked = False - self.invite_list = {} - - def is_char_available(self, char_id): - return char_id not in [x.char_id for x in self.clients] - - def get_rand_avail_char_id(self): - avail_set = set(range(len(self.server.char_list))) - set([x.char_id for x in self.clients]) - if len(avail_set) == 0: - raise AreaError('No available characters.') - return random.choice(tuple(avail_set)) - - def send_command(self, cmd, *args): - for c in self.clients: - c.send_command(cmd, *args) - - def send_host_message(self, msg): - self.send_command('CT', self.server.config['hostname'], msg) - - def set_next_msg_delay(self, msg_length): - delay = min(3000, 100 + 60 * msg_length) - self.next_message_time = round(time.time() * 1000.0 + delay) - - def is_iniswap(self, client, anim1, anim2, char): - if self.iniswap_allowed: - return False - if '..' in anim1 or '..' in anim2: - return True - for char_link in self.server.allowed_iniswaps: - if client.get_char_name() in char_link and char in char_link: - return False - return True - - def play_music(self, name, cid, length=-1): - self.send_command('MC', name, cid) - if self.music_looper: - self.music_looper.cancel() - if length > 0: - self.music_looper = asyncio.get_event_loop().call_later(length, - lambda: self.play_music(name, -1, length)) - - def can_send_message(self): - return (time.time() * 1000.0 - self.next_message_time) > 0 - - def change_hp(self, side, val): - if not 0 <= val <= 10: - raise AreaError('Invalid penalty value.') - if not 1 <= side <= 2: - raise AreaError('Invalid penalty side.') - if side == 1: - self.hp_def = val - elif side == 2: - self.hp_pro = val - self.send_command('HP', side, val) - - def change_background(self, bg): - if bg.lower() not in (name.lower() for name in self.server.backgrounds): - raise AreaError('Invalid background name.') - self.background = bg - self.send_command('BN', self.background) - - def change_background_mod(self, bg): - self.background = bg - self.send_command('BN', self.background) - - def change_status(self, value): - allowed_values = ('idle', 'building-open', 'building-full', 'casing-open', 'casing-full', 'recess') - if value.lower() not in allowed_values: - raise AreaError('Invalid status. Possible values: {}'.format(', '.join(allowed_values))) - self.status = value.upper() - - def change_doc(self, doc='No document.'): - self.doc = doc - - def add_to_judgelog(self, client, msg): - if len(self.judgelog) >= 10: - self.judgelog = self.judgelog[1:] - self.judgelog.append('{} ({}) {}.'.format(client.get_char_name(), client.get_ip(), msg)) - - def add_music_playing(self, client, name): - self.current_music_player = client.get_char_name() - self.current_music = name - - def get_evidence_list(self, client): - client.evi_list, evi_list = self.evi_list.create_evi_list(client) - return evi_list - - def broadcast_evidence_list(self): - """ - LE#&&# - - """ - for client in self.clients: - client.send_command('LE', *self.get_evidence_list(client)) - - def __init__(self, server): - self.server = server - self.cur_id = 0 - self.areas = [] - self.load_areas() - - def load_areas(self): - AREA_LIMIT = 1000 #This only affects the use of template ranges, not the number of areas you may have at any given time - area_parameters = {'area', 'background', 'bglock', 'evidence_mod', 'locking_allowed', - 'iniswap_allowed', 'rp_getarea_allowed', 'rp_getareas_allowed'} - default_parameters = {'area': 'Area ', 'background': 'gs4', 'bglock': False, #why was this set as a string - 'evidence_mod': 'FFA', 'locking_allowed': False, 'iniswap_allowed': True, - 'rp_getarea_allowed': True, 'rp_getareas_allowed': True} - area_templates = dict() - area_template_ranges = dict() - area_template_ranges_ordered = list() # This is done to force multiple apparitions of the same area in different ranges - # to be read in the correct order, so that for example, say area 4 appears in the 'default' and 'RP' template ranges, and in - # area_templates.yaml, 'default' appears first. With this, I'd like it so that it will first read the default values - # of the 'default' template, and then overwrite the parameters that appear in both 'default' and 'RP' with the ones - # from 'RP' - try: - with open('config/area_templates.yaml', 'r') as template_file: - init_area_templates = yaml.load(template_file) - if init_area_templates is None: - raise FileNotFoundError - except FileNotFoundError: - init_area_templates = dict() - - for item in init_area_templates: - if 'template_name' not in item: - if 'apply_template_ranges' not in item or not item['apply_template_ranges']: - continue - # This method performs the template area range checks - for template in item: - if template == 'apply_template_ranges': - continue #We don't want to check the template_application line, it has nothing useful - raw_areas = str(item[template]).split(", ") - affected_areas = set() - - for entry in raw_areas: - limits = entry.split("/") - if len(limits) > 2 or limits[0] == '' or limits[-1] == '': #Allows ez testing of length 1 and 2 lists - continue - try: - limits[0] = int(limits[0]) - limits[-1] = int(limits[-1]) - except ValueError: - continue - if limits[0] < 0 or limits[-1] > AREA_LIMIT or limits[0] > limits[-1]: - continue - for i in range(limits[0],limits[-1]+1): - affected_areas.add(i) - area_template_ranges[template] = affected_areas - area_template_ranges_ordered.append(template) - continue - - # The rest of this method deals with actually loading the templates in memory - area_templates[item['template_name']] = item - - #This is so that the area template does not include 'template_name' as an actual parameter - #while also doubling down as a way to determine if this is a 'default' template - template_name = area_templates[item['template_name']].pop('template_name') - # Be careful, the 'pop' instruction also removes the template_name parameter from 'item' (yay mutability) - # If you want to refer to the template name in this routine, you must use template_name or you will get a key error - if template_name == 'default': - for parameter in area_templates[template_name]: - default_parameters[parameter] = area_templates[template_name][parameter] - # The idea here is that if there is an actual 'default' template, DRO will use whatever values this default - # template has as a backup, and if for whatever reason this default template does not have whatever parameters - # we may need, it will fall back to some hard-coded values in order to prevent crashes - - with open('config/areas.yaml', 'r') as chars: - areas = yaml.load(chars) - - # Here's the established priority for determining area parameters - # 1. It will first look for manually defined parameters in areas.yaml - # 2. If some parameter is not manually set, it will look for the default value in the associated template - # 3. If it does not have an explicitly associated template, it will determine if the area belongs to a template area range - # 4. If none of the above, it will load hard-coded parameter values - - for item in areas: - for parameter in area_parameters: - if parameter not in item: - if 'template' in item and item['template'] in area_templates and parameter in area_templates[item['template']]: - item[parameter] = area_templates[item['template']][parameter] # The template's default parameters will only - # kick in if there are no valid parameters in the areas.yaml file, i.e. you can override template - # parameters if you so choose - else: - flag = False - for template in area_template_ranges_ordered: - if self.cur_id in area_template_ranges[template] and parameter in area_templates[template]: - item[parameter] = area_templates[template][parameter] - flag = True - if not flag: - item[parameter] = default_parameters[parameter] - - item['area'] = item['area'].replace('',str(self.cur_id)) - - self.areas.append( - self.Area(self.cur_id, self.server, item['area'], item['background'], - item['bglock'], item['evidence_mod'], item['locking_allowed'], - item['iniswap_allowed'], item['rp_getarea_allowed'], item['rp_getareas_allowed'])) - self.cur_id += 1 - - def default_area(self): - return self.areas[0] - - def get_area_by_name(self, name): - for area in self.areas: - if area.name == name: - return area - raise AreaError('Area not found.') - - def get_area_by_id(self, num): - for area in self.areas: - if area.id == num: - return area - raise AreaError('Area not found.') diff --git a/server/asset_manager.py b/server/asset_manager.py index 2b4920a59..08e6e0292 100644 --- a/server/asset_manager.py +++ b/server/asset_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -35,7 +36,8 @@ from server.subscriber import Publisher if typing.TYPE_CHECKING: - from client_manager import ClientManager + from server.client_manager import ClientManager + from server.hub_manager import _Hub from server.tsuserver import TsuserverDR @@ -44,7 +46,7 @@ class AssetManager(ABC): A quasi-abstract base class for managers of assets. """ - def __init__(self, server: TsuserverDR): + def __init__(self, server: TsuserverDR, hub: Union[_Hub, None] = None): """ Create an asset manager. @@ -52,13 +54,16 @@ def __init__(self, server: TsuserverDR): ---------- server : TsuserverDR The server this asset manager belongs to. + hub : _Hub, optional + The hub this asset manager belongs to. Defaults to None. """ self.server = server + self.hub = hub self.publisher = Publisher(self) @abstractmethod - def get_name(self) -> str: + def get_type_name(self) -> str: """ Return a brief human-readable description of the manager. @@ -101,8 +106,8 @@ def get_loader(self) -> Callable[[str, ], Any]: @abstractmethod def get_source_file(self) -> Union[str, None]: """ - Return the source file of the last asset the manager successfully loaded relative to the - root directory of the server, or None if the latest loaded asset was loaded raw. + Return the source file of the last asset list the manager successfully loaded relative to + the root directory of the server, or None if the latest loaded asset list was loaded raw. Returns ------- @@ -112,6 +117,21 @@ def get_source_file(self) -> Union[str, None]: raise NotImplementedError + @abstractmethod + def get_previous_source_file(self) -> Union[str, None]: + """ + Return the output that self.get_source_file() would have returned *before* the last + successful time an asset list was successfully loaded. + If no such call was ever made, return None. + + Returns + ------- + Union[str, None] + Previous source file or None. + """ + + raise NotImplementedError + @abstractmethod def get_custom_folder(self) -> str: """ @@ -127,7 +147,7 @@ def get_custom_folder(self) -> str: raise NotImplementedError @abstractmethod - def load_file(source_file: str) -> List: + def load_file(self, source_file: str) -> List: """ Load assets from a file relative to the server root directory. @@ -145,13 +165,13 @@ def load_file(source_file: str) -> List: raise NotImplementedError @abstractmethod - def load_raw(raw: Any) -> List: + def load_raw(self, yaml_contents: Any) -> List: """ Load assets from a Python representation. Parameters ---------- - raw : Any + yaml_contents : Any Assets to load Returns @@ -162,8 +182,21 @@ def load_raw(raw: Any) -> List: raise NotImplementedError + def is_default_file_loaded(self) -> bool: + """ + Decide whether the default file as specified by self.get_default_file() is currently + loaded or not. + + Returns + ------- + bool + True if the default file is loaded, False otherwise + """ + + return self.get_source_file() == self.get_default_file() + def command_list_load(self, client: ClientManager.Client, file: str, - notify_others: bool = True): + send_notifications: bool = True): """ Load an asset given by the player and notify the player and others if indicated. @@ -173,8 +206,8 @@ def command_list_load(self, client: ClientManager.Client, file: str, Player who requested the loading. file : str Location of the file relative to the server root folder. - notify_others : bool, optional - If other players of the server should be notified if the asset is successfully loaded, + send_notifications : bool, optional + If notifications should be sent if the asset is successfully loaded, by default True. Raises @@ -193,10 +226,10 @@ def command_list_load(self, client: ClientManager.Client, file: str, if not file: source_file = self.get_default_file() - msg = f'the default {self.get_name()} file' + msg = f'the default {self.get_type_name()} file' else: source_file = f'{self.get_custom_folder()}/{file}.yaml' - msg = f'the custom {self.get_name()} file `{source_file}`' + msg = f'the custom {self.get_type_name()} file `{source_file}`' fail_msg = f'Unable to load {msg}' try: @@ -217,12 +250,16 @@ def command_list_load(self, client: ClientManager.Client, file: str, raise ServerError(f'{fail_msg}: ' f'An asset syntax error occurred: `{exc}`.') else: - client.send_ooc(f'You have loaded {msg}.') - if notify_others: - client.send_ooc_others(f'{msg[0].upper()}{msg[1:]} has been loaded.', - is_officer=False) - client.send_ooc_others(f'{client.name} [{client.id}] has loaded {msg}.', - is_officer=True) + if send_notifications: + client.send_ooc(f'You have loaded {msg} in your hub.') + client.send_ooc_others(f'{msg[0].upper()}{msg[1:]} has been loaded in your hub.', + is_staff=False, in_hub=True) + client.send_ooc_others(f'{client.displayname} [{client.id}] has loaded {msg} in ' + f'your hub.', + is_staff=True, in_hub=True) + client.send_ooc_others(f'{client.displayname} [{client.id}] has loaded {msg} in ' + f'hub {client.hub.get_numerical_id()}.', + is_officer=True, in_hub=False) def command_list_info(self, client: ClientManager.Client): """ @@ -243,7 +280,7 @@ def command_list_info(self, client: ClientManager.Client): else: name = 'the default list' - client.send_ooc(f'The current {self.get_name()} is {name}.') + client.send_ooc(f'The current {self.get_type_name()} is {name}.') @abstractmethod def _check_structure(self): diff --git a/server/background_manager.py b/server/background_manager.py index a172d77ae..67df4598c 100644 --- a/server/background_manager.py +++ b/server/background_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -31,6 +32,7 @@ from server.validate.backgrounds import ValidateBackgrounds if typing.TYPE_CHECKING: + from server.hub_manager import _Hub from server.tsuserver import TsuserverDR class BackgroundManager(AssetManager): @@ -39,7 +41,7 @@ class BackgroundManager(AssetManager): loaded file or an adequate Python representation. """ - def __init__(self, server: TsuserverDR): + def __init__(self, server: TsuserverDR, hub: Union[_Hub, None] = None): """ Create a background manager object. @@ -47,14 +49,18 @@ def __init__(self, server: TsuserverDR): ---------- server: TsuserverDR The server this background manager belongs to. + hub : _Hub, optional + The hub this area manager belongs to. Defaults to None. """ - super().__init__(server) + super().__init__(server, hub=hub) self._backgrounds = ['default'] - self._source_file = 'config/backgrounds.yaml' + self._source_file = None + self._previous_source_file = None + self._default_background = self._backgrounds[0] - def get_name(self) -> str: + def get_type_name(self) -> str: """ Return `'background list'`. @@ -80,15 +86,15 @@ def get_default_file(self) -> str: def get_loader(self) -> Callable[[str, ], str]: """ - Return `self.server.load_backgrounds`. + Return `self.hub.load_backgrounds`. Returns ------- Callable[[str, ], str] - `self.server.load_backgrounds`. + `self.hub.load_backgrounds`. """ - return self.server.load_backgrounds + return self.hub.load_backgrounds def get_backgrounds(self) -> List[str]: """ @@ -116,17 +122,31 @@ def get_source_file(self) -> Union[str, None]: return self._source_file + def get_previous_source_file(self) -> Union[str, None]: + """ + Return the output that self.get_source_file() would have returned *before* the last + successful time a background list was successfully loaded. + If no such call was ever made, return None. + + Returns + ------- + Union[str, None] + Previous source file or None. + """ + + return self._previous_source_file + def get_custom_folder(self) -> str: """ - Return `'config/background_lists'`. + Return `'config/bg_lists'`. Returns ------- str - `'config/background_lists'`. + `'config/bg_lists'`. """ - return 'config/background_lists' + return 'config/bg_lists' def get_default_background(self) -> str: return self._default_background @@ -211,6 +231,8 @@ def load_raw(self, yaml_contents: List) -> List[str]: return output def _load_backgrounds(self, new_list: List[str], source_file: Union[str, None]) -> List[str]: + self._previous_source_file = self._source_file + lower = [name.lower() for name in new_list] self._backgrounds = lower self._source_file = source_file diff --git a/server/ban_manager.py b/server/ban_manager.py index 369f0fdf5..5e7ce7666 100644 --- a/server/ban_manager.py +++ b/server/ban_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -16,20 +17,27 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from __future__ import annotations + import ipaddress import json +import typing from server import logger from server.constants import Constants from server.exceptions import ServerError +if typing.TYPE_CHECKING: + from server.tsuserver import TsuserverDR class BanManager: - def __init__(self, server): + def __init__(self, server: TsuserverDR): self.bans = [] - self.load_banlist() self.server = server + self.load_banlist() + self.write_banlist() # TODO: Remove this after next major update + def load_banlist(self): try: with Constants.fopen('storage/banlist.json', 'r', encoding='utf-8') as banlist_file: @@ -44,8 +52,8 @@ def load_banlist(self): logger.log_pdebug(message) def write_banlist(self): - with open('storage/banlist.json', 'w') as banlist_file: - json.dump(self.bans, banlist_file) + with open('storage/banlist.json', 'w', encoding='utf-8') as banlist_file: + json.dump(self.bans, banlist_file, indent=4) def add_ban(self, ip): try: diff --git a/server/character_manager.py b/server/character_manager.py index 120a52c4c..d93691b56 100644 --- a/server/character_manager.py +++ b/server/character_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -20,13 +21,15 @@ import typing -from typing import Callable, List, Union +from typing import Callable, List, Tuple, Union from server.asset_manager import AssetManager from server.exceptions import CharacterError from server.validate.characters import ValidateCharacters if typing.TYPE_CHECKING: + from server.hub_manager import _Hub + from server.client_manager import ClientManager from server.tsuserver import TsuserverDR class CharacterManager(AssetManager): @@ -35,7 +38,7 @@ class CharacterManager(AssetManager): loaded file or an adequate Python representation. """ - def __init__(self, server: TsuserverDR): + def __init__(self, server: TsuserverDR, hub: Union[_Hub, None] = None): """ Create a character manager object. @@ -43,13 +46,16 @@ def __init__(self, server: TsuserverDR): ---------- server: TsuserverDR The server this character manager belongs to. + hub : _Hub, optional + The hub this character manager belongs to. Defaults to None. """ - super().__init__(server) + super().__init__(server, hub=hub) self._characters = [] - self._source_file = 'config/characters.yaml' + self._source_file = None + self._previous_source_file = None - def get_name(self) -> str: + def get_type_name(self) -> str: """ Return `'character list'`. @@ -75,15 +81,15 @@ def get_default_file(self) -> str: def get_loader(self) -> Callable[[str, ], str]: """ - Return `self.server.load_characters`. + Return `self.hub.load_characters`. Returns ------- Callable[[str, ], str] - `self.server.load_characters`. + `self.hub.load_characters`. """ - return self.server.load_characters + return self.hub.load_characters def get_characters(self) -> List[str]: """ @@ -111,17 +117,31 @@ def get_source_file(self) -> Union[str, None]: return self._source_file + def get_previous_source_file(self) -> Union[str, None]: + """ + Return the output that self.get_source_file() would have returned *before* the last + successful time a character list was successfully loaded. + If no such call was ever made, return None. + + Returns + ------- + Union[str, None] + Previous source file or None. + """ + + return self._previous_source_file + def get_custom_folder(self) -> str: """ - Return `'config/character_lists'`. + Return `'config/char_lists'`. Returns ------- str - `'config/character_lists'`. + `'config/char_lists'`. """ - return 'config/character_lists' + return 'config/char_lists' def validate_file(self, source_file: Union[str, None] = None) -> List[str]: if source_file is None: @@ -196,6 +216,8 @@ def load_raw(self, yaml_contents: List) -> List[str]: return output def _load_characters(self, new_list: List[str], source_file: Union[str, None]) -> List[str]: + self._previous_source_file = self._source_file + self._characters = new_list.copy() self._source_file = source_file @@ -204,6 +226,12 @@ def _load_characters(self, new_list: List[str], source_file: Union[str, None]) - def is_character(self, character: str) -> bool: return character in self._characters + def is_char_id_participant(self, char_id: Union[int, None]) -> bool: + # DO NOT UNCOMMENT. + # if not self.is_valid_character_id(char_id): + # return False + return char_id is not None and char_id >= 0 + def is_valid_character_id(self, char_id: Union[int, None]) -> bool: return char_id is None or len(self._characters) > char_id >= -1 @@ -225,6 +253,24 @@ def get_character_id_by_name(self, name: str) -> int: return i raise CharacterError.CharacterNotFoundError(f'Character {name} not found.') + def translate_character_id(self, client: ClientManager.Client, + old_char_name: str = None) -> Tuple[bool, Union[int, None]]: + if old_char_name is None: + old_char_name = client.get_char_name() + + if not client.has_participant_character(): + # Do nothing for spectators + return (False, client.char_id) + if old_char_name not in self._characters: + # Character no longer exists, so switch to spectator + client.send_ooc(f'After a change in the character list, your character is no ' + f'longer available. Switching to ' + f'{self.server.config["spectator_name"]}.') + return (True, -1) + + target_char_id = self._characters.index(old_char_name) + return (client.char_id != target_char_id, target_char_id) + def _check_structure(self): """ Assert that all invariants specified in the class description are maintained. diff --git a/server/client_changearea.py b/server/client_changearea.py index 5884a55c1..9b2760114 100644 --- a/server/client_changearea.py +++ b/server/client_changearea.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -17,18 +18,19 @@ # along with this program. If not, see . from __future__ import annotations + import typing from typing import List, Set, Tuple, Union + +from server import logger +from server.constants import Constants +from server.exceptions import AreaError, ClientError, TaskError + if typing.TYPE_CHECKING: # Avoid circular referencing from server.area_manager import AreaManager from server.client_manager import ClientManager - -import time - -from server import logger -from server.exceptions import ClientError, AreaError -from server.constants import Constants + from server.hub_manager import _Hub class ClientChangeArea: @@ -56,6 +58,9 @@ def check_change_area(self, area: AreaManager.Area, manually send them out outside this function. """ + if more_unavail_chars is None: + more_unavail_chars = set() + client = self.client captured_messages = list() @@ -65,7 +70,10 @@ def check_change_area(self, area: AreaManager.Area, # Check if player has waited a non-zero movement delay if not client.is_staff() and client.is_movement_handicapped and not override_effects: - start, length, name, _ = client.server.tasker.get_task_args(client, ['as_handicap']) + task = client.server.task_manager.get_task(client, 'as_handicap') + start = task.creation_time + length = task.parameters['length'] + name = task.parameters['handicap_name'] _, remain_text = Constants.time_remaining(start, length) raise ClientError("You are still under the effects of movement handicap '{}'. " "Please wait {} before changing areas." @@ -85,8 +93,6 @@ def check_change_area(self, area: AreaManager.Area, if client.ipid not in area.invite_list: if area.is_locked and not client.is_staff(): raise ClientError('That area is locked.', code='ChArLocked') - if area.is_gmlocked and not client.is_mod and not client.is_gm: - raise ClientError('That area is gm-locked.', code='ChArGMLocked') if area.is_modlocked and not client.is_mod: raise ClientError('That area is mod-locked.', code='ChArModLocked') @@ -96,16 +102,65 @@ def check_change_area(self, area: AreaManager.Area, raise ClientError('The passage to this area is locked.', code='ChArUnreachable') - # Check if current character is taken in the new area + char_name = client.get_char_name() new_char_id = client.char_id - if not area.is_char_available(client.char_id, allow_restricted=client.is_staff(), - more_unavail_chars=more_unavail_chars): + + def _translate_char_id(old_char_id: Union[int, None]) -> Tuple[bool, Union[int, None]]: + new_hub = area.hub + if new_hub == client.hub: + return True, old_char_id + # Check if spectator or non-selected + if not new_hub.character_manager.is_char_id_participant(old_char_id): + return True, old_char_id + + new_characters = new_hub.character_manager.get_characters() + new_chars = {char: num for (num, char) in enumerate(new_characters)} + char_name = client.hub.character_manager.get_character_name(old_char_id) + + if char_name in new_chars: + return True, new_chars[char_name] + + return False, -1 + + + def _update_char_id() -> Tuple[bool, Union[int, None]]: + # Check if using a non-participating character. Those are trivial + if not client.has_participant_character(): + return True, client.char_id + + # Check if hub (possibly different from old_area.hub) has character + # Because if not, change to spectator trivially + if not area.hub.character_manager.is_character(char_name): + return True, -1 + + # If in same hub, updated_more_unavail_chars + if area.hub == client.hub: + new_more_unavail_chars = more_unavail_chars + else: + new_more_unavail_chars = set(_translate_char_id(char_id) + for char_id in more_unavail_chars) + + # Check if current character is not (taken or restricted) in the new area + if area.is_char_available(client.char_id, allow_restricted=client.is_staff(), + more_unavail_chars=new_more_unavail_chars): + return True, client.char_id + + # Check if can pick a new character try: + # Random character rather than spectator (as would happen with hub changes) + # This ensures that players continue having participant characters within hubs + # when an attempt to change areas occurs. + # This allows intra-hub games that span several areas that require participant + # characters not have players trivially be kicked new_char_id = area.get_rand_avail_char_id(allow_restricted=client.is_staff(), - more_unavail_chars=more_unavail_chars) + more_unavail_chars=new_more_unavail_chars) + return True, new_char_id except AreaError: - raise ClientError('No available characters in that area.', - code='ChArNoCharacters') + return False, -1 + + valid_new_char_id, new_char_id = _update_char_id() + if not valid_new_char_id: + raise ClientError('No available characters in that area.', code='ChArNoCharacters') return new_char_id, captured_messages @@ -190,7 +245,8 @@ def notify_me(self, area: AreaManager.Area, old_dname: str, client.send_ooc_others('(X) Client {} had their showname `{}` removed in your zone ' 'due to it conflicting with the showname of another player in ' 'the same area ({}).' - .format(client.id, client.showname, area.id), is_zstaff=area) + .format(client.id, client.showname, area.id), + is_zstaff=area, in_hub=area.hub) client.change_showname('', target_area=area) logger.log_server('{} had their showname removed due it being used in the new area.' .format(client.ipid), client) @@ -204,7 +260,8 @@ def notify_me(self, area: AreaManager.Area, old_dname: str, client.send_ooc_others('(X) Client {} had their character showname `{}` removed in ' 'your zone due to it conflicting with the showname of another ' 'player in the same area ({}).' - .format(client.id, client.showname, area.id), is_zstaff=area) + .format(client.id, client.showname, area.id), + is_zstaff=area, in_hub=area.hub) client.change_character_ini_details(client.char_folder, '') logger.log_server('{} had their character showname removed due it being used in the ' 'new area.'.format(client.ipid), client) @@ -441,13 +498,13 @@ def notify_others(self, area: AreaManager.Area, old_dname: str, if old_area.in_zone and area.in_zone != old_area.in_zone: client.send_ooc_others('(X) {} [{}] has left your zone ({}->{}).' .format(old_dname, client.id, old_area.id, area.id), - is_zstaff=old_area) + is_zstaff=old_area, in_hub=old_area.hub) # Check if entering a zone if area.in_zone and area.in_zone != old_area.in_zone: client.send_ooc_others('(X) {} [{}] has entered your zone ({}->{}).' .format(new_dname, client.id, old_area.id, area.id), - is_zstaff=area) + is_zstaff=area, in_hub=area.hub) # Raise multiclienting warning to the watchers of the new zone if needed # Note that this implementation does not have an off-by-one error, as the incoming # client is technically still not in an area within the zone, so only one client being @@ -456,12 +513,13 @@ def notify_others(self, area: AreaManager.Area, old_dname: str, if [c for c in client.get_multiclients() if c.area.in_zone == area.in_zone]: client.send_ooc_others('(X) Warning: Client {} is multiclienting in your zone. ' 'Do /multiclients {} to take a look.' - .format(client.id, client.id), is_zstaff=area) + .format(client.id, client.id), + is_zstaff=area, in_hub=area.hub) # Assuming this is not a spectator... # If autopassing, send OOC messages - if not ignore_autopass and client.has_character(): + if not ignore_autopass and client.has_participant_character(): self.notify_others_moving(client, old_area, '{} has left to the {}.'.format(old_dname, area.name), 'You hear footsteps going out of the room.') @@ -523,19 +581,19 @@ def notify_others_moving(self, client: ClientManager.Client, area: AreaManager.A nbyd = '' if client.autopass: - client.send_ooc_others(staff, in_area=area, is_zstaff_flex=True) + client.send_ooc_others(staff, in_area=area, is_zstaff_flex=True, in_hub=area.hub) else: - client.send_ooc_others(staff, in_area=area, is_zstaff_flex=True, + client.send_ooc_others(staff, in_area=area, is_zstaff_flex=True, in_hub=area.hub, pred=lambda c: c.get_nonautopass_autopass) - client.send_ooc_others(nbnd, in_area=area, is_zstaff_flex=True, + client.send_ooc_others(nbnd, in_area=area, is_zstaff_flex=True, in_hub=area.hub, pred=lambda c: not c.get_nonautopass_autopass) - client.send_ooc_others(nbnd, in_area=area, is_zstaff_flex=False, to_blind=False, - to_deaf=False) - client.send_ooc_others(ybnd, in_area=area, is_zstaff_flex=False, to_blind=True, - to_deaf=False) - client.send_ooc_others(nbyd, in_area=area, is_zstaff_flex=False, to_blind=False, - to_deaf=True) + client.send_ooc_others(nbnd, in_area=area, is_zstaff_flex=False, in_hub=area.hub, + to_blind=False, to_deaf=False) + client.send_ooc_others(ybnd, in_area=area, is_zstaff_flex=False, in_hub=area.hub, + to_blind=True, to_deaf=False) + client.send_ooc_others(nbyd, in_area=area, is_zstaff_flex=False, in_hub=area.hub, + to_blind=False, to_deaf=True) # Blind and deaf get nothing def notify_others_blood(self, client: ClientManager.Client, area: AreaManager.Area, @@ -613,16 +671,16 @@ def notify_others_blood(self, client: ClientManager.Client, area: AreaManager.Ar staff = staff.replace('no longer bleeding and sneaking.', 'no longer bleeding, but is still sneaking.') # Ugly - client.send_ooc_others(norm, is_zstaff_flex=False, in_area=area, to_blind=False, - to_deaf=False) - client.send_ooc_others(ybnd, is_zstaff_flex=False, in_area=area, to_blind=True, - to_deaf=False) - client.send_ooc_others(nbyd, is_zstaff_flex=False, in_area=area, to_blind=False, - to_deaf=True) - client.send_ooc_others(ybyd, is_zstaff_flex=False, in_area=area, to_blind=True, - to_deaf=True) + client.send_ooc_others(norm, is_zstaff_flex=False, in_area=area, in_hub=area.hub, + to_blind=False, to_deaf=False) + client.send_ooc_others(ybnd, is_zstaff_flex=False, in_area=area, in_hub=area.hub, + to_blind=True, to_deaf=False) + client.send_ooc_others(nbyd, is_zstaff_flex=False, in_area=area, in_hub=area.hub, + to_blind=False, to_deaf=True) + client.send_ooc_others(ybyd, is_zstaff_flex=False, in_area=area, in_hub=area.hub, + to_blind=True, to_deaf=True) if send_to_staff: - client.send_ooc_others(staff, is_zstaff_flex=True, in_area=area) + client.send_ooc_others(staff, is_zstaff_flex=True, in_area=area, in_hub=area.hub) def notify_others_status(self, client: ClientManager.Client, area: AreaManager.Area, name: str, status: str = 'stay'): @@ -669,34 +727,155 @@ def notify_others_status(self, client: ClientManager.Client, area: AreaManager.A nbyd = vague_mes staff = staff_mes.format(' while sneaking') - client.send_ooc_others(norm, is_zstaff_flex=False, in_area=area, to_blind=False, - to_deaf=False) - client.send_ooc_others(ybnd, is_zstaff_flex=False, in_area=area, to_blind=True, - to_deaf=False) - client.send_ooc_others(nbyd, is_zstaff_flex=False, in_area=area, to_blind=False, - to_deaf=True) - client.send_ooc_others(staff, is_zstaff_flex=True, in_area=area) - - def change_area(self, area: AreaManager.Area, override_all: bool = False, - override_passages: bool = False, override_effects: bool = False, - ignore_bleeding: bool = False, ignore_followers: bool = False, - ignore_autopass: bool = False, - ignore_checks: bool = False, ignore_notifications: bool = False, - more_unavail_chars: Set[int] = None, change_to: int = None, - from_party: bool = False): + client.send_ooc_others(norm, is_zstaff_flex=False, in_area=area, in_hub=area.hub, + to_blind=False, to_deaf=False) + client.send_ooc_others(ybnd, is_zstaff_flex=False, in_area=area, in_hub=area.hub, + to_blind=True, to_deaf=False) + client.send_ooc_others(nbyd, is_zstaff_flex=False, in_area=area, in_hub=area.hub, + to_blind=False, to_deaf=True) + client.send_ooc_others(staff, is_zstaff_flex=True, in_area=area, in_hub=area.hub) + + + def _do_change_area( + self, + area: AreaManager.Area, + override_passages: bool = False, + override_effects: bool = False, + ignore_bleeding: bool = False, + ignore_followers: bool = False, + ignore_autopass: bool = False, + ignore_checks: bool = False, + ignore_notifications: bool = False, + more_unavail_chars: Set[int] = None, + change_to: int = None, + from_party: bool = False + ) -> Tuple[bool, bool, bool]: + + client = self.client + old_area = client.area + + # If player is in a party, do special method instead of this + if from_party: + client.server.party_manager.move_party(client.party, client, area) + return False, False, False + + # It also returns the character name that the player ended up, if it changed. + if ignore_checks: + if change_to: + new_char_id, mes = change_to, list() + else: + new_char_id, mes = client.char_id, list() + else: + new_char_id, mes = client.check_change_area( + area, + override_passages=override_passages, + override_effects=override_effects, + more_unavail_chars=more_unavail_chars + ) + + # Code after this line assumes that the area change will be successful + # (but has not yet been performed) + client.new_area = area + + # Send client messages that could have been generated during the change area check + for message in mes: + client.send_ooc(message) + + # Perform the character switch if new area has a player with the current char + # or the char is restricted there. + old_char = client.get_char_name() + old_dname = client.displayname + if new_char_id != client.char_id: + client.change_character(new_char_id, target_area=area, announce_zwatch=False) + new_char = client.get_char_name() + if old_char in area.restricted_chars: + client.send_ooc('Your character was restricted in your new area, switched ' + 'to `{}`.'.format(new_char)) + client.send_ooc_others('(X) Client {} had their character changed from `{}` to ' + '`{}` in your zone as their old character was ' + 'restricted in their new area ({}).' + .format(client.id, old_char, new_char, area.id), + is_zstaff=area, in_hub=area.hub) + else: + client.send_ooc('Your character was unavailable in your new area, switched to `{}`.' + .format(client.get_char_name())) + client.send_ooc_others('(X) Client {} had their character changed from `{}` to ' + '`{}` in your zone as their old character was ' + 'unavailable in their new area ({}).' + .format(client.id, old_char, new_char, area.id), + is_zstaff=area, in_hub=area.hub) + + # IC lock bypasses only last the old area + if client.can_bypass_iclock: + client.send_ooc('You have lost your IC lock bypass as you moved to a ' + 'different area.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] has lost their IC ' + f'lock bypass as they moved to a different area. ' + f'({area.id})', + is_zstaff_flex=old_area, in_hub=old_area.hub) + client.can_bypass_iclock = False + + if ignore_notifications: + return True, False, False + + if client.is_staff() or (not client.is_blind and area.lights): + others_visible = client.get_visible_clients(area) - {client} + if others_visible: + verb = 'is' if client.is_staff() else 'seems' + populated_message = f'\nThe area {verb} populated.' + else: + verb = "isn't" if client.is_staff() else "doesn't seem" + populated_message = f"\nThe area {verb} populated." + else: + populated_message = '' + + client.send_ooc(f'Changed area to {area.name}.{populated_message}') + logger.log_server(f'[{client.get_char_name()}]Changed area from ' + f'{old_area.name} ({old_area.id}) to ' + f'{area.name} ({area.id}).', client) + found_something, ding_something = client.notify_change_area( + area, old_dname, ignore_bleeding=ignore_bleeding, + ignore_autopass=ignore_autopass) + + old_area.publisher.publish('area_client_left', { + 'client': client, + 'new_area': area, + 'old_displayname': old_dname, + 'ignore_bleeding': ignore_bleeding, + 'ignore_autopass': ignore_autopass, + }) + area.publisher.publish('area_client_entered', { + 'client': client, + 'old_displayname': old_dname, + 'ignore_bleeding': ignore_bleeding, + 'ignore_autopass': ignore_autopass, + }) + return True, found_something, ding_something + + def change_area( + self, + area: AreaManager.Area, + override_passages: bool = False, + override_effects: bool = False, + ignore_bleeding: bool = False, + ignore_followers: bool = False, + ignore_autopass: bool = False, + ignore_checks: bool = False, + ignore_notifications: bool = False, + more_unavail_chars: Set[int] = None, + change_to: int = None, + from_party: bool = False + ): """ PARAMETERS: *override_passages: ignore passages existing from the source area to the target area *override_effects: ignore current effects, such as movement handicaps *ignore_bleeding: not add blood to the area if the character is moving, - such as from /area_kick or AFK kicks + such as from /summon or AFK kicks *ignore_followers: avoid sending the follow command to followers (e.g. using /follow) *ignore_autopass: avoid sending autopass notifications *restrict_characters: additional characters to mark as restricted, others than the one used in the area or area restricted. - *override_all: perform the area change regarldess of area restrictions and send no - RP related notifications (only useful for complete area reload). In particular, - override_all being False performs all the checks and announces the area change in OOC. *ignore_checks: ignore the change area checks. *ignore_notifications: ignore the area notifications except character change. *more_unavail_chars: additional characters in the target area to mark as taken. @@ -708,126 +887,71 @@ def change_area(self, area: AreaManager.Area, override_all: bool = False, client = self.client old_area = client.area - found_something, ding_something = False, False - - if not override_all: - # All the code that could raise errors goes here - - # If player is in a party, do special method instead of this - if from_party: - client.server.party_manager.move_party(client.party, client, area) - return - - # It also returns the character name that the player ended up, if it changed. - if not ignore_checks: - new_char_id, mes = client.check_change_area(area, - override_passages=override_passages, - override_effects=override_effects, - more_unavail_chars=more_unavail_chars) - else: - if change_to: - new_char_id, mes = change_to, list() - else: - new_char_id, mes = client.char_id, list() - - # Code after this line assumes that the area change will be successful - # (but has not yet been performed) - client.new_area = area - - # Send client messages that could have been generated during the change area check - for message in mes: - client.send_ooc(message) - - # Perform the character switch if new area has a player with the current char - # or the char is restricted there. - old_char = client.get_char_name() - old_dname = client.displayname - if new_char_id != client.char_id: - client.change_character(new_char_id, target_area=area, announce_zwatch=False) - new_char = client.get_char_name() - if old_char in area.restricted_chars: - client.send_ooc('Your character was restricted in your new area, switched ' - 'to `{}`.'.format(new_char)) - client.send_ooc_others('(X) Client {} had their character changed from `{}` to ' - '`{}` in your zone as their old character was ' - 'restricted in their new area ({}).' - .format(client.id, old_char, new_char, area.id), - is_zstaff=area) - else: - client.send_ooc('Your character was taken in your new area, switched to `{}`.' - .format(client.get_char_name())) - client.send_ooc_others('(X) Client {} had their character changed from `{}` to ' - '`{}` in your zone as their old character was ' - 'taken in their new area ({}).' - .format(client.id, old_char, new_char, area.id), - is_zstaff=area) - - # IC lock bypasses only last the old area - if client.can_bypass_iclock: - client.send_ooc('You have lost your IC lock bypass as you moved to a ' - 'different area.') - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] has lost their IC ' - f'lock bypass as they moved to a different area. ' - f'({area.id})', is_zstaff_flex=old_area) - client.can_bypass_iclock = False - - if not ignore_notifications: - client.send_ooc('Changed area to {}.[{}]'.format(area.name, area.status)) - logger.log_server('[{}]Changed area from {} ({}) to {} ({}).' - .format(client.get_char_name(), old_area.name, old_area.id, - area.name, area.id), client) - #logger.log_rp('[{}]Changed area from {} ({}) to {} ({}).' - # .format(client.get_char_name(), old_area.name, old_area.id, - # old_area.name, old_area.id), client) - - found_something, ding_something = client.notify_change_area( - area, old_dname, ignore_bleeding=ignore_bleeding, - ignore_autopass=ignore_autopass) - - old_area.publisher.publish('area_client_left', { - 'client': client, - 'new_area': area, - 'old_displayname': old_dname, - 'ignore_bleeding': ignore_bleeding, - 'ignore_autopass': ignore_autopass, - }) - area.publisher.publish('area_client_entered', { - 'client': client, - 'old_displayname': old_dname, - 'ignore_bleeding': ignore_bleeding, - 'ignore_autopass': ignore_autopass, - }) + old_dname = client.displayname + old_char_name = client.get_char_name() + + # All the code that could raise errors goes here + proceed, found_something, ding_something = self._do_change_area( + area, + override_passages=override_passages, + override_effects=override_effects, + ignore_bleeding=ignore_bleeding, + ignore_followers=ignore_followers, + ignore_autopass=ignore_autopass, + ignore_checks=ignore_checks, + ignore_notifications=ignore_notifications, + more_unavail_chars=more_unavail_chars, + change_to=change_to, + from_party=from_party + ) + + if not proceed: + return old_area.remove_client(client) client.area = area - client.new_area = area # Update again, as the above if may not have run + client.new_area = area # Update again, as it may have not been set in _do_change_area area.new_client(client) - self.post_area_changed(old_area, area, - found_something=found_something, - ding_something=ding_something, - old_dname=old_dname, override_all=override_all, - override_passages=override_passages, - override_effects=override_effects, - ignore_bleeding=ignore_bleeding, - ignore_followers=ignore_followers, - ignore_autopass=ignore_autopass, - ignore_checks=ignore_checks, - ignore_notifications=ignore_notifications, - more_unavail_chars=more_unavail_chars, - change_to=change_to, - from_party=from_party) - - def post_area_changed(self, old_area: Union[None, AreaManager.Area], area: AreaManager.Area, - found_something: bool = False, - ding_something: bool = False, - old_dname: str = '', - override_all: bool = False, - override_passages: bool = False, override_effects: bool = False, - ignore_bleeding: bool = False, ignore_followers: bool = False, - ignore_autopass: bool = False, - ignore_checks: bool = False, ignore_notifications: bool = False, - more_unavail_chars: Set[int] = None, change_to: int = None, - from_party: bool = False): + + self.post_area_changed( + old_area, + area, + found_something=found_something, + ding_something=ding_something, + old_dname=old_dname, + old_char_name=old_char_name, + override_passages=override_passages, + override_effects=override_effects, + ignore_bleeding=ignore_bleeding, + ignore_followers=ignore_followers, + ignore_autopass=ignore_autopass, + ignore_checks=ignore_checks, + ignore_notifications=ignore_notifications, + more_unavail_chars=more_unavail_chars, + change_to=change_to, + from_party=from_party + ) + + def post_area_changed( + self, + old_area: Union[None, AreaManager.Area], + area: AreaManager.Area, + found_something: bool = False, + ding_something: bool = False, + old_dname: str = '', + old_char_name: str = '', + + override_passages: bool = False, + override_effects: bool = False, + ignore_bleeding: bool = False, + ignore_followers: bool = False, + ignore_autopass: bool = False, + ignore_checks: bool = False, + ignore_notifications: bool = False, + more_unavail_chars: Set[int] = None, + change_to: int = None, + from_party: bool = False + ): client = self.client if not old_dname: old_dname = client.displayname @@ -868,17 +992,25 @@ def post_area_changed(self, old_area: Union[None, AreaManager.Area], area: AreaM client.send_music_list_view() # Update music list to include new area's reachable areas # If new area has lurk callout timer, reset it to that, provided it makes sense client.check_lurk() - client.server.tasker.create_task(client, ['as_afk_kick', area.afk_delay, area.afk_sendto]) + client.server.task_manager.new_task(client, 'as_afk_kick', { + 'afk_delay': area.afk_delay, + 'afk_sendto': area.afk_sendto, + }) # Try and restart handicap if needed try: - _, length, name, announce_if_over = client.server.tasker.get_task_args(client, - ['as_handicap']) - except (ValueError, KeyError): + task = client.server.task_manager.get_task(client, 'as_handicap') + except TaskError.TaskNotFoundError: pass else: - client.server.tasker.create_task(client, - ['as_handicap', time.time(), length, name, - announce_if_over]) + length = task.parameters['length'] + name = task.parameters['handicap_name'] + announce_if_over = task.parameters['announce_if_over'] + + client.server.task_manager.new_task(client, 'as_handicap', { + 'length': length, + 'handicap_name': name, + 'announce_if_over': announce_if_over, + }) # For old area, check if there are no remaining clients, and if so, end any existing # lurk callout timer that may have been imposed on the area @@ -887,7 +1019,7 @@ def post_area_changed(self, old_area: Union[None, AreaManager.Area], area: AreaM mes = ('(X) The lurk callout timer in area {} has been ended as there is no one ' 'left there.'.format(old_area.name)) client.send_ooc(mes, is_zstaff_flex=old_area) - client.send_ooc_others(mes, is_zstaff_flex=old_area) + client.send_ooc_others(mes, is_zstaff_flex=old_area, in_hub=old_area.hub) if area.id not in client.remembered_locked_passages: client.remembered_locked_passages[area.id] = set() @@ -909,6 +1041,52 @@ def post_area_changed(self, old_area: Union[None, AreaManager.Area], area: AreaM 'ignore_bleeding': ignore_bleeding, }) - if client.followedby and not ignore_followers and not override_all: + if old_area and old_area.hub != area.hub: + client.hub = area.hub + client.send_ooc(f'Changed hub to hub {client.hub.get_numerical_id()}.') + + old_characters = old_area.hub.character_manager.get_characters() + new_characters = client.hub.character_manager.get_characters() + + if old_characters != new_characters: + if client.packet_handler.ALLOWS_CHAR_LIST_RELOAD: + client.send_character_list(characters=new_characters) + + should_change, change_to_char_id = ( + client.hub.character_manager.translate_character_id( + client, old_char_name=old_char_name + )) + if should_change: + client.change_character(change_to_char_id, force=True, + old_char=old_char_name) + else: + client.send_ooc('After a change in the character list, your client character ' + 'list is no longer synchronized. Please rejoin the server.') + client.change_character(-1, old_char=old_char_name) + + if client.is_officer(): + client.hub.add_leader(client) + client.send_music_list_view() + elif client.is_staff(): + client.send_ooc('Logging out of GM as you changed hub.') + client.logout() + # logout already does send_music_list_view + else: + client.send_music_list_view() + + if client.autoglance and (client.is_staff() or (area.lights and not client.is_blind)): + (elevated, has_area_description, area_description, + _, _) = client.area.get_look_output_for(client) + msg = '' + if elevated: + msg += '(X) ' + + if (has_area_description and + (client.is_staff() or (not client.is_blind and area.lights))): + msg += f'You note this about the area: `{area_description}`.' + client.send_ooc(msg) + + + if client.followedby and not ignore_followers: for c in client.followedby: c.follow_area(area) diff --git a/server/client_manager.py b/server/client_manager.py index 54f1bfd4e..4e2ba54fa 100644 --- a/server/client_manager.py +++ b/server/client_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -18,43 +19,49 @@ from __future__ import annotations +import datetime +import random +import time import typing -from typing import Any, Callable, List, Optional, Set, Tuple, Dict, Union +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union + +from server import client_changearea, clients, logger +from server.constants import Constants, TargetType +from server.exceptions import (AreaError, ClientError, HubError, PartyError, + TaskError, TrialError) +from server.hub_manager import _Hub +from server.music_manager import PersonalMusicManager +from server.subscriber import Publisher -from server.music_manager import MusicManager if typing.TYPE_CHECKING: + from asyncio.proactor_events import _ProactorSocketTransport + # Avoid circular referencing from server.area_manager import AreaManager from server.network.ao_protocol import AOProtocol from server.tsuserver import TsuserverDR from server.zone_manager import ZoneManager -import datetime -import random -import time - -from server import clients -from server import client_changearea -from server import logger -from server.exceptions import AreaError, ClientError, GameError, PartyError, TrialError -from server.constants import TargetType, Constants -from server.subscriber import Publisher - - class ClientManager: class Client: - def __init__(self, server: TsuserverDR, transport, user_id: int, ipid: int, - protocol: AOProtocol = None): + def __init__( + self, + server: TsuserverDR, + hub: _Hub, + transport: _ProactorSocketTransport, + user_id: int, + ipid: int, + protocol: AOProtocol = None + ): self.server = server + self.hub = hub self.transport = transport self.protocol = protocol - self.ip = transport.get_extra_info('peername')[0] if transport else "127.0.0.1" self.area_changer = client_changearea.ClientChangeArea(self) self.required_packets_received = set() # Needs to have length 2 to actually connect self.can_askchaa = True # Needs to be true to process an askchaa packet self.version = ('Undefined', 'Undefined') # AO version used established through ID pack self.packet_handler = clients.ClientDRO1d2d2() - self.bad_version = False self.publisher = Publisher(self) self.disconnected = False @@ -69,16 +76,19 @@ def __init__(self, server: TsuserverDR, transport, user_id: int, ipid: int, self.showname = '' self.joined = time.time() self.last_active = Constants.get_time() + self.viewing_hubs = False self.ever_chose_character = False self.ever_outbounded_gamemode = False self.ever_outbounded_time_of_day = False - self.music_manager = MusicManager(server) + self.music_manager = PersonalMusicManager(server, hub=None) # Avoid doing an OS call for a new client - self.music_manager.transfer_contents_from_manager(self.server.music_manager) + self.music_manager.transfer_contents_from_manager( + self.server.hub_manager.get_default_managee().music_manager + ) - self.area = server.area_manager.default_area() + self.area = hub.area_manager.default_area() self.new_area = self.area # It is different from self.area in transition to a new area self.party = None self.is_mod = False @@ -94,7 +104,6 @@ def __init__(self, server: TsuserverDR, transport, user_id: int, ipid: int, self.muted_global = False self.pm_mute = False - self.in_rp = False self.autopass = False self.disemvowel = False self.disemconsonant = False @@ -107,8 +116,7 @@ def __init__(self, server: TsuserverDR, transport, user_id: int, ipid: int, self.followedby = set() self.showname_history = list() self.is_transient = False - self.handicap = None - self.handicap_backup = None # Use if custom handicap is overwritten with a server one + self.old_handicap = None # Use if custom handicap is overwritten with a server one self.is_movement_handicapped = False self.show_shownames = True self.is_bleeding = False @@ -117,8 +125,8 @@ def __init__(self, server: TsuserverDR, transport, user_id: int, ipid: int, self.last_ooc_message = '' self.first_person = False self.forward_sprites = True - self.last_received_ic = [None, None, None] - self.last_received_ic_notme = [None, None, None] + self.last_received_ic = (None, None, None) + self.last_received_ic_notme = (None, None, None) self.is_blind = False self.is_deaf = False self.is_gagged = False @@ -137,6 +145,7 @@ def __init__(self, server: TsuserverDR, transport, user_id: int, ipid: int, self.paranoia = 2 self.notecard = '' self.is_mindreader = False + self.autoglance = False # Pairing stuff self.charid_pair = -1 @@ -158,13 +167,6 @@ def __init__(self, server: TsuserverDR, transport, user_id: int, ipid: int, self.mflood_mutelength = self.server.config['music_change_floodguard']['mute_length'] self.mflood_log = list() - @property - def music_list(self) -> List[Dict[str, Any]]: - Constants.warn_deprecated('client.music_list', - 'client.music_manager.get_music', - '4.4') - return self.music_manager.get_music() - def send_command(self, command: str, *args: List): self.protocol.data_send(command, *args) @@ -221,24 +223,50 @@ def prepare_command(self, identifier, dargs): {'contents': final_dargs.copy()}) return final_dargs, to_send - def send_ooc(self, msg: str, username: str = None, allow_empty: bool = False, - is_staff=None, is_officer=None, in_area=None, not_to=None, part_of=None, - to_blind=None, to_deaf=None, is_zstaff=None, is_zstaff_flex=None, - pred: Callable[[ClientManager.Client], bool] = None): + def send_ooc( + self, + msg: str, + username: str = None, + allow_empty: bool = False, + + is_staff: Union[bool, None] = None, + is_officer: Union[bool, None] = None, + is_mod: Union[bool, None] = None, + in_hub: Union[bool, _Hub, Set[_Hub], None] = True, + in_area: Union[bool, AreaManager.Area, Set[AreaManager.Area], None] = None, + not_to: Union[Set[ClientManager.Client], None] = None, + part_of: Union[Set[ClientManager.Client], None] = None, + to_blind: Union[bool, None] = None, + to_deaf: Union[bool, None] = None, + is_zstaff: Union[bool, AreaManager.Area, None] = None, + is_zstaff_flex: Union[bool, AreaManager.Area, None] = None, + pred: Callable[[ClientManager.Client], bool] = None + ): + if not allow_empty and not msg: + return + if pred is None: pred = lambda x: True if not_to is None: not_to = set() - if not allow_empty and not msg: - return if username is None: username = self.server.config['hostname'] - cond = Constants.build_cond(self, is_staff=is_staff, is_officer=is_officer, - in_area=in_area, not_to=not_to, part_of=part_of, - to_blind=to_blind, to_deaf=to_deaf, - is_zstaff=is_zstaff, is_zstaff_flex=is_zstaff_flex, - pred=pred) + cond = Constants.build_cond( + self, + is_staff=is_staff, + is_officer=is_officer, + is_mod=is_mod, + in_hub=in_hub, + in_area=in_area, + not_to=not_to, + part_of=part_of, + to_blind=to_blind, + to_deaf=to_deaf, + is_zstaff=is_zstaff, + is_zstaff_flex=is_zstaff_flex, + pred=pred + ) if cond(self): self.send_command_dict('CT', { @@ -246,11 +274,25 @@ def send_ooc(self, msg: str, username: str = None, allow_empty: bool = False, 'message': msg, }) - def send_ooc_others(self, msg: str, username: str = None, allow_empty: bool = False, - is_staff=None, is_officer=None, in_area=None, - not_to: Set = None, part_of=None, - to_blind=None, to_deaf=None, is_zstaff=None, is_zstaff_flex=None, - pred: Callable[[ClientManager.Client], bool] = None): + def send_ooc_others( + self, + msg: str, + username: str = None, + allow_empty: bool = False, + + is_staff: Union[bool, None] = None, + is_officer: Union[bool, None] = None, + is_mod: Union[bool, None] = None, + in_hub: Union[bool, _Hub, Set[_Hub], None] = True, + in_area: Union[bool, AreaManager.Area, Set[AreaManager.Area], None] = None, + not_to: Union[Set[ClientManager.Client], None] = None, + part_of: Union[Set[ClientManager.Client], None] = None, + to_blind: Union[bool, None] = None, + to_deaf: Union[bool, None] = None, + is_zstaff: Union[bool, AreaManager.Area, None] = None, + is_zstaff_flex: Union[bool, AreaManager.Area, None] = None, + pred: Callable[[ClientManager.Client], bool] = None + ): if not allow_empty and not msg: return @@ -261,39 +303,56 @@ def send_ooc_others(self, msg: str, username: str = None, allow_empty: bool = Fa if username is None: username = self.server.config['hostname'] - cond = Constants.build_cond(self, is_staff=is_staff, is_officer=is_officer, - in_area=in_area, not_to=not_to.union({self}), - part_of=part_of, to_blind=to_blind, to_deaf=to_deaf, - is_zstaff=is_zstaff, is_zstaff_flex=is_zstaff_flex, - pred=pred) + cond = Constants.build_cond( + self, + is_staff=is_staff, + is_officer=is_officer, + is_mod=is_mod, + in_hub=in_hub, + in_area=in_area, + not_to=not_to.union({self}), + part_of=part_of, + to_blind=to_blind, + to_deaf=to_deaf, + is_zstaff=is_zstaff, + is_zstaff_flex=is_zstaff_flex, + pred=pred + ) + self.server.make_all_clients_do("send_ooc", msg, pred=cond, allow_empty=allow_empty, username=username) - def send_ic(self, - params: List = None, - sender: ClientManager.Client = None, - bypass_text_replace: bool = False, - bypass_deafened_starters: bool = False, - use_last_received_sprites: bool = False, - gag_replaced: bool = False, - pred: Callable[[ClientManager.Client], bool] = None, - not_to: Set[ClientManager.Client] = None, - part_of: Set[ClientManager.Client] = None, - is_staff: bool = None, - is_officer: bool = None, - is_zstaff: bool = None, - is_zstaff_flex: bool = None, - in_area: bool = None, - to_blind: bool = None, - to_deaf: bool = None, - msg=None, - folder=None, - pos=None, - char_id=None, - ding=None, - color=None, - showname=None, - hide_character=0): + def send_ic( + self, + params: List = None, + sender: ClientManager.Client = None, + bypass_text_replace: bool = False, + bypass_deafened_starters: bool = False, + use_last_received_sprites: bool = False, + gag_replaced: bool = False, + + is_staff: Union[bool, None] = None, + is_officer: Union[bool, None] = None, + is_mod: Union[bool, None] = None, + in_hub: Union[bool, _Hub, Set[_Hub], None] = True, + in_area: Union[bool, AreaManager.Area, Set[AreaManager.Area], None] = None, + not_to: Union[Set[ClientManager.Client], None] = None, + part_of: Union[Set[ClientManager.Client], None] = None, + to_blind: Union[bool, None] = None, + to_deaf: Union[bool, None] = None, + is_zstaff: Union[bool, AreaManager.Area, None] = None, + is_zstaff_flex: Union[bool, AreaManager.Area, None] = None, + pred: Callable[[ClientManager.Client], bool] = None, + + msg=None, + folder=None, + pos=None, + char_id=None, + ding=None, + color=None, + showname=None, + hide_character=0 + ): # sender is the client who sent the IC message # self is who is receiving the IC message at this particular moment @@ -332,11 +391,21 @@ def send_ic(self, # Check if receiver is actually meant to receive the message. Bail out early if not. # FIXME: First argument should be sender, not self. Using in_area=True fails otherwise - cond = Constants.build_cond(self, is_staff=is_staff, is_officer=is_officer, - in_area=in_area, not_to=not_to, - part_of=part_of, to_blind=to_blind, to_deaf=to_deaf, - is_zstaff=is_zstaff, is_zstaff_flex=is_zstaff_flex, - pred=pred) + cond = Constants.build_cond( + self, + is_staff=is_staff, + is_officer=is_officer, + is_mod=is_mod, + in_hub=in_hub, + in_area=in_area, + not_to=not_to, + part_of=part_of, + to_blind=to_blind, + to_deaf=to_deaf, + is_zstaff=is_zstaff, + is_zstaff_flex=is_zstaff_flex, + pred=pred + ) if not cond(self): return # If self is ignoring sender, now is the moment to discard @@ -357,7 +426,7 @@ def send_ic(self, # Remove None values from pargs, which could have happened while setting default values # from the function call to_pop = list() - for (key, value) in pargs.items(): + for key in pargs: if pargs[key] is None: to_pop.append(key) for key in to_pop: @@ -379,9 +448,9 @@ def pop_if_there(dictionary, argument): (sender == self and self.first_person) or (sender and not sender.forward_sprites)): # last_sender: Client who actually sent the new message - # last_apparent_sender: Client whose sprites were used for the last message + # last_seen_sender: Client whose sprites were used for the last message # last_args: "MS" arguments to the last message - # Do note last_sender != last_apparent_sender if a person receives a message + # Do note last_sender != last_seen_sender if a person receives a message # from someone in not forward sprites mode. In that case, last_sender is # updated with this new client, but last apparent_sender is not. @@ -389,27 +458,27 @@ def pop_if_there(dictionary, argument): # guarantee ourselves we do not pick the last message that could possibly # be self if sender == self and self.first_person: - last_apparent_sender, last_args, last_apparent_args = self.last_received_ic_notme + last_seen_sender, last_args, last_seen_args = self.last_received_ic_notme else: - last_apparent_sender, last_args, last_apparent_args = self.last_received_ic + last_seen_sender, last_args, last_seen_args = self.last_received_ic # Make sure showing previous sender makes sense. If it does not make sense now, # it will not make sense later. # If last sender is no longer connected, do not show previous sender - if not last_apparent_sender or not self.server.is_client(last_apparent_sender): + if not last_seen_sender or not self.server.is_client(last_seen_sender): pargs['anim'] = '../../misc/blank' - self.last_received_ic_notme = [None, None, None] - self.last_received_ic = [None, None, None] + self.last_received_ic_notme = (None, None, None) + self.last_received_ic = (None, None, None) # If last apparent sender and self are not in the same area, do not show # previous sender - elif self.area != last_apparent_sender.area: + elif self.area != last_seen_sender.area: pargs['anim'] = '../../misc/blank' - self.last_received_ic_notme = [None, None, None] - self.last_received_ic = [None, None, None] + self.last_received_ic_notme = (None, None, None) + self.last_received_ic = (None, None, None) # If last sender has changed character, do not show previous sender - elif ((last_apparent_sender.char_id != last_apparent_args['char_id'] or - last_apparent_sender.char_folder != last_apparent_args['folder'])): + elif ((last_seen_sender.char_id != last_seen_args['char_id'] or + last_seen_sender.char_folder != last_seen_args['folder'])): # We need to check for iniswaps as well, to account for this possibility: # 1. A and B are in the same room. A as in first person mode # 2. B talks to A and moves to another room @@ -418,21 +487,21 @@ def pop_if_there(dictionary, argument): # 5. If A had received no other message in the meantime, clear the last # character seen. pargs['anim'] = '../../misc/blank' - self.last_received_ic_notme = [None, None, None] - self.last_received_ic = [None, None, None] + self.last_received_ic_notme = (None, None, None) + self.last_received_ic = (None, None, None) # Do not show previous sender if # 1. Previous sender is sneaked and is not GM, and # 2. It is not the case self is in a party, the same one as previous sender, # and self is sneaked - elif (not last_apparent_sender.is_visible and - not last_apparent_sender.is_staff() and - not (self.party and self.party == last_apparent_sender.party + elif (not last_seen_sender.is_visible and + not last_seen_sender.is_staff() and + not (self.party and self.party == last_seen_sender.party and not self.is_visible)): # It will still be the case self will reveal themselves by talking # They will however see last sender if needed pargs['anim'] = '../../misc/blank' - self.last_received_ic_notme = [None, None, None] - self.last_received_ic = [None, None, None] + self.last_received_ic_notme = (None, None, None) + self.last_received_ic = (None, None, None) # Otherwise, show message else: pargs['folder'] = last_args['folder'] @@ -485,7 +554,7 @@ def pop_if_there(dictionary, argument): (not pargs['msg'].startswith(allowed_starters) and not pargs['msg'] in allowed_messages) or (sender and sender.is_gagged and gag_replaced)): - pargs['msg'] = '(Your ears are ringing)' + pargs['msg'] = '[Your ears are ringing]' if (not self.packet_handler.ALLOWS_REPEATED_MESSAGES_FROM_SAME_CHAR and self.send_deaf_space): pargs['msg'] = pargs['msg'] + ' ' @@ -535,7 +604,7 @@ def pop_if_there(dictionary, argument): # This step also takes care of filtering out the packet arguments that the client # cannot parse, and also make sure they are in the correct order. - final_pargs, to_send = self.prepare_command('ms', pargs) + final_pargs, _ = self.prepare_command('ms', pargs) # Keep track of packet details in case this was sent by someone else # This is used, for example, for first person mode @@ -545,78 +614,120 @@ def pop_if_there(dictionary, argument): # Only update apparent sender if sender was in forward sprites mode if sender and sender.forward_sprites: - self.last_received_ic_notme[0] = sender - self.last_received_ic_notme[2] = final_pargs - self.last_received_ic_notme[1] = final_pargs + self.last_received_ic_notme = ( + sender, + final_pargs, + final_pargs, + ) + else: + self.last_received_ic_notme = ( + self.last_received_ic_notme[0], + final_pargs, + self.last_received_ic_notme[2], + ) # Moreover, keep track of last received IC message # This is used for forward sprites mode. if sender and sender.forward_sprites: - self.last_received_ic[0] = sender - self.last_received_ic[2] = final_pargs - self.last_received_ic[1] = final_pargs + self.last_received_ic = ( + sender, + final_pargs, + final_pargs, + ) + else: + self.last_received_ic = ( + self.last_received_ic[0], + final_pargs, + self.last_received_ic[2], + ) self.send_command_dict('MS', final_pargs) - def send_ic_others(self, - params: List = None, - sender: ClientManager.Client = None, - bypass_text_replace: bool = False, - bypass_deafened_starters: bool = False, - use_last_received_sprites: bool = False, - gag_replaced: bool = False, - pred: Callable[[ClientManager.Client], bool] = None, - not_to: Set[ClientManager.Client] = None, - part_of: Set[ClientManager.Client] = None, - is_staff: bool = None, - is_officer: bool = None, - is_zstaff: bool = None, - is_zstaff_flex: bool = None, - in_area: bool = None, - to_blind: bool = None, - to_deaf: bool = None, - msg=None, - folder=None, - pos=None, - char_id=None, - ding=None, - color=None, - showname=None, - hide_character=0): + def send_ic_others( + self, + params: List = None, + sender: ClientManager.Client = None, + bypass_text_replace: bool = False, + bypass_deafened_starters: bool = False, + use_last_received_sprites: bool = False, + gag_replaced: bool = False, + + is_staff: Union[bool, None] = None, + is_officer: Union[bool, None] = None, + is_mod: Union[bool, None] = None, + in_hub: Union[bool, _Hub, Set[_Hub], None] = True, + in_area: Union[bool, AreaManager.Area, Set[AreaManager.Area], None] = None, + not_to: Union[Set[ClientManager.Client], None] = None, + part_of: Union[Set[ClientManager.Client], None] = None, + to_blind: Union[bool, None] = None, + to_deaf: Union[bool, None] = None, + is_zstaff: Union[bool, AreaManager.Area, None] = None, + is_zstaff_flex: Union[bool, AreaManager.Area, None] = None, + pred: Callable[[ClientManager.Client], bool] = None, + + msg=None, + folder=None, + pos=None, + char_id=None, + ding=None, + color=None, + showname=None, + hide_character=0 + ): if not_to is None: not_to = {self} else: not_to = not_to.union({self}) - cond = Constants.build_cond(self, is_staff=is_staff, is_officer=is_officer, - in_area=in_area, not_to=not_to.union({self}), - part_of=part_of, to_blind=to_blind, to_deaf=to_deaf, - is_zstaff=is_zstaff, is_zstaff_flex=is_zstaff_flex, - pred=pred) - self.server.make_all_clients_do("send_ic", pred=cond, - params=params, - sender=sender, - bypass_text_replace=bypass_text_replace, - bypass_deafened_starters=bypass_deafened_starters, - use_last_received_sprites=use_last_received_sprites, - gag_replaced=gag_replaced, - msg=msg, - folder=folder, - pos=pos, - char_id=char_id, - ding=ding, - color=color, - showname=showname, - hide_character=hide_character) + cond = Constants.build_cond( + self, + is_staff=is_staff, + is_officer=is_officer, + is_mod=is_mod, + in_hub=in_hub, + in_area=in_area, + not_to=not_to.union({self}), + part_of=part_of, + to_blind=to_blind, + to_deaf=to_deaf, + is_zstaff=is_zstaff, + is_zstaff_flex=is_zstaff_flex, + pred=pred + ) + self.server.make_all_clients_do( + "send_ic", + pred=cond, + params=params, + sender=sender, + bypass_text_replace=bypass_text_replace, + bypass_deafened_starters=bypass_deafened_starters, + use_last_received_sprites=use_last_received_sprites, + gag_replaced=gag_replaced, + msg=msg, + folder=folder, + pos=pos, + char_id=char_id, + ding=ding, + color=color, + showname=showname, + hide_character=hide_character + ) def send_ic_attention(self, ding: bool = True): int_ding = 1 if ding else 0 - self.send_ic(msg='(Something catches your attention)', ding=int_ding, hide_character=1) + self.send_ic(msg=self.area.noteworthy_text, ding=int_ding, hide_character=1) def send_ic_blankpost(self): if self.packet_handler.ALLOWS_INVISIBLE_BLANKPOSTS: self.send_ic(msg='', hide_character=1, bypass_text_replace=True) + def send_character_list(self, characters: List[str] = None): + if characters is None: + characters = self.hub.character_manager.get_characters() + self.send_command_dict('SC', { + 'chars_ao2_list': characters, + }) + def send_background(self, name: str = None, pos: str = None, tod_backgrounds: Dict[str, str] = None): """ @@ -668,12 +779,6 @@ def send_health(self, side=None, health=None): def send_music(self, name=None, char_id=None, showname=None, force_same_restart=None, loop=None, channel=None, effects=None): - if (not self.packet_handler.HAS_CLIENTSIDE_MUSIC_LOOPING - and self.packet_handler.REPLACES_BASE_OPUS_FOR_MP3): - if name in self.server.new_110_music: - name = name.replace('.opus', '.mp3') - name = '/'.join(name.split('/')[1:]) - self.send_command_dict('MC', { 'name': name, 'char_id': char_id, @@ -769,10 +874,10 @@ def is_valid_name(self, name: str) -> bool: name_ws = name.replace(' ', '') if not name_ws or name_ws.isdigit(): return False - #for client in self.server.get_clients(): - #print(client.name == name) - #if client.name == name: - #return False + # for client in self.hub.get_players(): + # print(client.name == name) + # if client.name == name: + # return False return True @property @@ -789,14 +894,12 @@ def showname_else_char_showname(self) -> str: return self.showname return self.char_showname - def has_character(self, char_id: int = None) -> bool: - if char_id is None: - char_id = self.char_id - - return char_id is not None and char_id >= 0 + def has_participant_character(self) -> bool: + return self.hub.character_manager.is_char_id_participant(self.char_id) def change_character(self, char_id: int, force: bool = False, target_area: AreaManager.Area = None, + old_char: str = None, announce_zwatch: bool = True): # Do not run this code if player is still doing server handshake if self.char_id is None: @@ -807,35 +910,43 @@ def change_character(self, char_id: int, force: bool = False, # area if I just did self.area if target_area is None: target_area = self.area + if old_char is None: + old_char = self.get_char_name() + old_char_id = self.char_id - old_char, old_char_id = self.get_char_name(), self.char_id - - if not self.server.character_manager.is_valid_character_id(char_id): + if not target_area.hub.character_manager.is_valid_character_id(char_id): raise ClientError('Invalid character ID.') + + new_char = self.hub.character_manager.get_character_name(char_id) if not target_area.is_char_available(char_id, allow_restricted=self.is_staff()): if force: for client in self.area.clients: if client.char_id == char_id: client.char_select() - if client != self: - client.send_ooc('You were forced off your character.') - self.send_ooc(f'You forced client {client.id} off their ' - f'character.') - self.send_ooc_others(f'{self.name} [{self.id}] forced client ' - f'{client.id} off their character.', - is_officer=True, not_to={client}) + if client == self: + continue + + client.send_ooc('You were forced off your character.') + self.send_ooc(f'You forced client {client.id} off their character.') + self.send_ooc_others(f'{self.name} [{self.id}] forced client ' + f'{client.id} off their character.', + is_officer=True, in_hub=None, not_to={client}) else: - raise ClientError('Character {} not available.' - .format(self.get_char_name(char_id))) + raise ClientError(f'Character {new_char} not available.') # Code after this comment assumes the character change will be successful self.ever_chose_character = True - if not self.has_character() and self.has_character(char_id=char_id): + has_char_before = self.has_participant_character() + has_char_after = self.hub.character_manager.is_char_id_participant(char_id) + + if has_char_after and not has_char_before: # No longer spectator? # Now bound by AFK rules - self.server.tasker.create_task(self, ['as_afk_kick', self.area.afk_delay, - self.area.afk_sendto]) + self.server.task_manager.new_task(self, 'as_afk_kick', { + 'afk_delay': self.area.afk_delay, + 'afk_sendto': self.area.afk_sendto, + }) # And to lurk callouts, if any, provided not staff member self.check_lurk() @@ -846,19 +957,19 @@ def change_character(self, char_id: int, force: bool = False, f'and you are not logged in.') self.unfollow_user() - elif self.has_character() and not self.has_character(char_id=char_id): + elif has_char_before and not has_char_after: # Now a spectator? # No longer bound to AFK rules try: - self.server.tasker.remove_task(self, ['as_afk_kick']) - except KeyError: + self.server.task_manager.delete_task(self, 'as_afk_kick') + except TaskError.TaskNotFoundError: pass # And to lurk callouts self.check_lurk() self.char_id = char_id # Assumes players are not iniswapped initially, waiting for chrini packet - self.char_folder = self.get_char_name() + self.char_folder = new_char self.char_showname = '' self.pos = 'wit' @@ -866,7 +977,7 @@ def change_character(self, char_id: int, force: bool = False, self.send_ooc_others('(X) Client {} has changed from character `{}` to `{}` in ' 'your zone ({}).' .format(self.id, old_char, self.char_folder, self.area.id), - is_zstaff=target_area) + is_zstaff=target_area, in_hub=target_area.hub) self.send_command_dict('PV', { 'client_id': self.id, @@ -875,7 +986,9 @@ def change_character(self, char_id: int, force: bool = False, }) self.publisher.publish('client_change_character', { 'old_char_id': old_char_id, + 'old_char_name': old_char, 'new_char_id': char_id, + 'new_char_name': new_char, }) logger.log_server('[{}]Changed character from {} to {}.' .format(self.area.id, old_char, self.get_char_name()), self) @@ -921,14 +1034,22 @@ def reload_character(self): self.change_character(self.char_id, force=True) def get_area_and_music_list_view(self): - area_list = self.server.area_manager.get_client_view(self, from_area=self.area) + area_list = self.hub.area_manager.get_client_view(self, from_area=self.area) music_list = self.music_manager.get_client_view() return area_list+music_list def send_music_list_view(self): - area_list = self.server.area_manager.get_client_view(self, from_area=self.area) - music_list = self.music_manager.get_client_view() + if self.viewing_hubs: + area_list = self.hub.manager.get_client_view(self) + else: + area_list = self.hub.area_manager.get_client_view(self, from_area=self.area) + + if (self.music_manager.is_default_file_loaded() + and self.music_manager.if_default_show_hub_music): + music_list = self.hub.music_manager.get_client_view() + else: + music_list = self.music_manager.get_client_view() if self.packet_handler.HAS_DISTINCT_AREA_AND_MUSIC_LIST_OUTGOING_PACKETS: # DRO 1.1.0+, KFO and AO2.8.4+ deals with music lists differently than older clients @@ -944,21 +1065,6 @@ def send_music_list_view(self): 'music_ao2_list': area_list+music_list, }) - def reload_music_list(self, new_music_file=None): - """ - Rebuild the music list so that it only contains the target area's - reachable areas+music. Useful when moving areas/logging in or out. - """ - - Constants.warn_deprecated('client.reload_music_list', - 'client.send_music_list_view', - '4.4',) - - if new_music_file: - self.music_manager.load_file(new_music_file) - - self.send_music_list_view() - def check_change_area(self, area: AreaManager.Area, override_passages: bool = False, override_effects: bool = False, @@ -976,15 +1082,36 @@ def notify_change_area(self, area: AreaManager.Area, old_char: str, just_me=just_me) def check_lurk(self): - if self.area.lurk_length > 0 and not self.is_staff() and self.has_character(): - self.server.tasker.create_task(self, ['as_lurk', self.area.lurk_length]) + if self.area.lurk_length > 0 and not self.is_staff() and self.has_participant_character(): + self.server.task_manager.new_task(self, 'as_lurk', { + 'length': self.area.lurk_length, + }) else: # Otherwise, end any existing lurk, if there is one try: - self.server.tasker.remove_task(self, ['as_lurk']) - except KeyError: + self.server.task_manager.delete_task(self, 'as_lurk') + except TaskError.TaskNotFoundError: pass - def change_area(self, area: AreaManager.Area, override_all: bool = False, + def change_hub(self, hub: _Hub, + override_effects: bool = False, + ignore_bleeding: bool = False, ignore_followers: bool = False, + ignore_autopass: bool = False, + ignore_checks: bool = False, ignore_notifications: bool = False, + more_unavail_chars: Set[int] = None, + change_to: int = None, from_party: bool = False): + if hub == self.hub: + raise ClientError('User is already in target hub.') + + self.change_area( + hub.area_manager.default_area(), + override_passages=True, # Overriden + override_effects=override_effects, ignore_bleeding=ignore_bleeding, + ignore_autopass=ignore_autopass, + ignore_followers=ignore_followers, ignore_checks=ignore_checks, + ignore_notifications=ignore_notifications, change_to=change_to, + more_unavail_chars=more_unavail_chars, from_party=from_party) + + def change_area(self, area: AreaManager.Area, override_passages: bool = False, override_effects: bool = False, ignore_bleeding: bool = False, ignore_followers: bool = False, ignore_autopass: bool = False, @@ -992,7 +1119,7 @@ def change_area(self, area: AreaManager.Area, override_all: bool = False, more_unavail_chars: Set[int] = None, change_to: int = None, from_party: bool = False): self.area_changer.change_area( - area, override_all=override_all, override_passages=override_passages, + area, override_passages=override_passages, override_effects=override_effects, ignore_bleeding=ignore_bleeding, ignore_autopass=ignore_autopass, ignore_followers=ignore_followers, ignore_checks=ignore_checks, @@ -1003,7 +1130,6 @@ def post_area_changed(self, old_area: Union[None, AreaManager.Area], area: AreaM found_something: bool = False, ding_something: bool = False, old_dname: str = '', - override_all: bool = False, override_passages: bool = False, override_effects: bool = False, ignore_bleeding: bool = False, ignore_followers: bool = False, ignore_autopass: bool = False, @@ -1014,7 +1140,7 @@ def post_area_changed(self, old_area: Union[None, AreaManager.Area], area: AreaM old_area, area, found_something=found_something, ding_something=ding_something, - old_dname=old_dname, override_all=override_all, + old_dname=old_dname, override_passages=override_passages, override_effects=override_effects, ignore_bleeding=ignore_bleeding, @@ -1170,17 +1296,20 @@ def change_visibility(self, new_status: bool): # delay than the server's sneaked handicap and restore it (as by default the server # will take the largest handicap when dealing with the automatic sneak handicap) try: - _, _, name, _ = self.server.tasker.get_task_args(self, ['as_handicap']) - except KeyError: + task = self.server.task_manager.get_task(self, 'as_handicap') + except TaskError.TaskNotFoundError: pass else: + name = task.parameters['handicap_name'] if name == "Sneaking": - if self.server.config['sneak_handicap'] > 0 and self.handicap_backup: + if self.server.config['sneak_handicap'] > 0 and self.old_handicap: # Only way for a handicap backup to exist and to be in this situation is # for the player to had a custom handicap whose length was shorter than # the server's sneak handicap, then was set to be sneaking, then was # revealed. From this, we can recover the old handicap backup - _, old_length, old_name, old_announce_if_over = self.handicap_backup[1] + old_length = self.old_handicap.parameters['length'] + old_name = self.old_handicap.parameters['handicap_name'] + old_announce_if_over = self.old_handicap.parameters['announce_if_over'] msg = ('(X) {} was [{}] automatically imposed their old movement ' 'handicap "{}" of length {} seconds after being revealed in ' @@ -1191,11 +1320,13 @@ def change_visibility(self, new_status: bool): self.send_ooc('You were automatically imposed your former movement ' 'handicap "{}" of length {} seconds when changing areas.' .format(old_name, old_length)) - self.server.tasker.create_task(self, ['as_handicap', time.time(), - old_length, old_name, - old_announce_if_over]) + self.server.task_manager.new_task(self, 'as_handicap', { + 'length': old_length, + 'handicap_name': old_name, + 'announce_if_over': old_announce_if_over, + }) else: - self.server.tasker.remove_task(self, ['as_handicap']) + self.server.task_manager.delete_task(self, 'as_handicap') logger.log_server('{} is no longer sneaking.'.format(self.ipid), self) else: # Changed to invisible (e.g. through /sneak) @@ -1208,20 +1339,25 @@ def change_visibility(self, new_status: bool): # 2. The player has no movement handicap or one shorter than the sneak handicap if shandicap > 0: try: - _, length, _, _ = self.server.tasker.get_task_args(self, ['as_handicap']) + task = self.server.task_manager.get_task(self, 'as_handicap') + + length = task.parameters['length'] if length < shandicap: msg = ('(X) {} [{}] was automatically imposed the longer movement ' 'handicap "Sneaking" of length {} seconds in area {} ({}).' .format(self.displayname, self.id, shandicap, self.area.name, self.area.id)) self.send_ooc_others(msg, is_zstaff_flex=True) - raise KeyError # Lazy way to get there, but it works - except KeyError: + raise ValueError # Lazy way to get there, but it works + except (TaskError.TaskNotFoundError, ValueError): self.send_ooc('You were automatically imposed a movement handicap ' '"Sneaking" of length {} seconds when changing areas.' .format(shandicap)) - self.server.tasker.create_task(self, ['as_handicap', time.time(), shandicap, - "Sneaking", True]) + self.server.task_manager.new_task(self, 'as_handicap', { + 'length': shandicap, + 'handicap_name': 'Sneaking', + 'announce_if_over': True, + }) logger.log_server('{} is now sneaking.'.format(self.ipid), self) @@ -1238,21 +1374,28 @@ def set_timed_effects(self, effects: Set[Constants.Effects], length: float): for effect in effects: name = effect.name async_name = effect.async_name - new_args = [async_name, time.time(), length, effect] try: - args = self.server.tasker.get_task_args(self, [async_name]) - except KeyError: - # New effect - self.server.tasker.create_task(self, new_args) + task = self.server.task_manager.get_task(self, async_name) + except TaskError.TaskNotFoundError: + self.server.task_manager.new_task(self, async_name, { + 'length': length, + 'effect': effect, + 'new_value': True, + }) resulting_effects[name] = (length, False) else: + old_start = task.creation_time + old_length = task.parameters['length'] # Effect existed before, check if need to replace it with a shorter effect - old_start, old_length, _ = args old_remaining, _ = Constants.time_remaining(old_start, old_length) if length < old_remaining: # Replace with shorter timed effect - self.server.tasker.create_task(self, new_args) + self.server.task_manager.new_task(self, async_name, { + 'length': length, + 'effect': effect, + 'new_value': True, + }) resulting_effects[name] = (length, True) else: # Do not replace, current effect's time is shorter @@ -1266,37 +1409,40 @@ def change_handicap(self, setting: bool, length: int = 1, name: str = '', self.send_ooc('You were imposed a movement handicap "{}" of length {} seconds when ' 'changing areas.'.format(name, length)) - self.server.tasker.create_task(self, ['as_handicap', time.time(), length, name, - announce_if_over]) - self.handicap = (self.server.tasker.get_task(self, ['as_handicap']), - self.server.tasker.get_task_args(self, ['as_handicap'])) - self.handicap_backup = (self.server.tasker.get_task(self, ['as_handicap']), - self.server.tasker.get_task_args(self, ['as_handicap'])) + task = self.server.task_manager.new_task(self, 'as_handicap', { + 'length': length, + 'handicap_name': name, + 'announce_if_over': announce_if_over, + }) + self.old_handicap = task return name else: try: - _, _, old_name, _ = self.server.tasker.get_task_args(self, ['as_handicap']) - except KeyError: + task = self.server.task_manager.get_task(self, 'as_handicap') + except TaskError.TaskNotFoundError: raise ClientError else: + old_name = task.parameters['handicap_name'] self.send_ooc('Your movement handicap "{}" when changing areas was removed.' .format(old_name)) - self.handicap = None - self.handicap_backup = None - self.server.tasker.remove_task(self, ['as_handicap']) + self.old_handicap = None + self.server.task_manager.delete_task(self, 'as_handicap') if self.area.in_zone and self.area.in_zone.is_property('Handicap'): length, name, announce_if_over = self.area.in_zone.get_property('Handicap') - self.send_ooc_others(f'(X) Warning: {self.displayname} [{self.id}] lost ' + self.send_ooc_others( + f'(X) Warning: {self.displayname} [{self.id}] lost ' f'their zone movement handicap by virtue of having their ' f'handicap removed. Add it again with /zone_handicap_add {self.id}', - is_zstaff_flex=True) + is_zstaff_flex=True + ) if not self.is_visible and self.server.config['sneak_handicap'] > 0: - self.send_ooc_others(f'(X) Warning: {self.displayname} [{self.id}] lost ' - f'their sneaking handicap by virtue of having their ' - f'handicap removed. Add it again with /handicap ' - f'{self.id} {self.server.config["sneak_handicap"]} ' - f'Sneaking', is_zstaff_flex=True) + self.send_ooc_others( + f'(X) Warning: {self.displayname} [{self.id}] lost their sneaking handicap ' + f'by virtue of having their handicap removed. Add it again with /handicap ' + f'{self.id} {self.server.config["sneak_handicap"]} Sneaking', + is_zstaff_flex=True + ) return old_name def refresh_remembered_status(self, @@ -1376,7 +1522,8 @@ def follow_user(self, target: ClientManager.Client): # Warn zone watchers of the area of the target self.send_ooc_others(f'(X) {self.displayname} [{self.id}] started following ' f'{target.displayname} [{target.id}] in your zone ' - f'({self.area.id}).', is_zstaff=target.area) + f'({self.area.id}).', + is_zstaff=target.area, in_hub=target.area.hub) def unfollow_user(self): if not self.following: @@ -1386,39 +1533,38 @@ def unfollow_user(self): f'at {Constants.get_time()}.') self.send_ooc_others(f'(X) {self.displayname} [{self.id}] stopped following ' f'{self.following.displayname} [{self.following.id}] in your zone ' - f'({self.area.id}).', is_zstaff=self.following.area) + f'({self.area.id}).', + is_zstaff=self.following.area, in_hub=self.following.area.hub) self.following.followedby.remove(self) self.following = None - def follow_area(self, area: ClientManager.Client, just_moved: bool = True): + def follow_area(self, area: AreaManager.Area, just_moved: bool = True): # just_moved if True assumes the case where the followed user just moved # It being false is the case where, when the following started, the followed user was # in another area, and thus the followee is moved automtically + + name = (area.name if area.hub == self.hub + else f'{area.name} in hub {area.hub.get_numerical_id()}') + if just_moved: if self.is_staff(): - self.send_ooc('Followed user moved to {} at {}' - .format(area.name, Constants.get_time())) + self.send_ooc(f'Followed user moved to area {name} at {Constants.get_time()}.') else: - self.send_ooc(f'Followed user moved to area {area.name}.') + self.send_ooc(f'Followed user moved to area {name}.') else: - self.send_ooc('Followed user was at {}'.format(area.name)) + self.send_ooc(f'Followed user was in area {name}.') try: self.change_area(area, override_passages=True, override_effects=True, ignore_bleeding=True, ignore_autopass=True, ignore_followers=True) except ClientError as error: - self.send_ooc('Unable to follow to {}: {}'.format(area.name, error)) + self.send_ooc(f'Unable to follow to area {name}: `{error}`.') def send_area_list(self): msg = '=== Areas ===' lock = {True: '[LOCKED]', False: ''} - for i, area in enumerate(self.server.area_manager.get_areas()): - owner = 'FREE' - if area.owned: - for client in [x for x in area.clients if x.is_cm]: - owner = 'MASTER: {}'.format(client.get_char_name()) - break - locked = area.is_gmlocked or area.is_modlocked or area.is_locked + for i, area in enumerate(self.hub.area_manager.get_areas()): + locked = area.is_modlocked or area.is_locked if self.is_staff(): n_clt = len([c for c in area.clients if c.char_id is not None]) @@ -1432,12 +1578,23 @@ def send_area_list(self): def send_limited_area_list(self): msg = '=== Areas ===' - for i, area in enumerate(self.server.area_manager.get_areas()): + for i, area in enumerate(self.hub.area_manager.get_areas()): msg += '\r\nArea {}: {}'.format(i, area.name) if self.area == area: msg += ' [*]' self.send_ooc(msg) + def send_limited_hub_list(self): + msg = '=== Hubs ===' + for i, hub in self.hub.manager.get_managee_numerical_ids_to_managees().items(): + name = hub.get_name() + if not name: + name = hub.get_id() + msg += '\r\nHub {}: {}'.format(i, name) + if self.hub == hub: + msg += ' [*]' + self.send_ooc(msg) + def get_visible_clients(self, area: AreaManager.Area, mods=False, as_mod=None, only_my_multiclients=False) -> Set[ClientManager.Client]: @@ -1480,7 +1637,7 @@ def get_area_info(self, area_id: int, mods, as_mod=None, include_shownames=False if include_ipid is None and as_mod: include_ipid = True - area = self.server.area_manager.get_area_by_id(area_id) + area = self.hub.area_manager.get_area_by_id(area_id) clients = self.get_visible_clients(area, mods=mods, as_mod=as_mod, only_my_multiclients=only_my_multiclients) sorted_clients = sorted(clients, key=lambda x: x.get_char_name()) @@ -1532,7 +1689,7 @@ def prepare_area_info(self, current_area: AreaManager.Area, area_id: int, getarea_restricted = (area_id >= 0 and not self.area.rp_getarea_allowed) if getareas_restricted or getarea_restricted: raise ClientError('This command has been restricted to authorized users only ' - 'in this area while in RP mode.') + 'in this area.') if not self.area.lights: raise ClientError('The lights are off, so you cannot see anything.') @@ -1542,7 +1699,7 @@ def prepare_area_info(self, current_area: AreaManager.Area, area_id: int, # all areas info if area_id == -1: - areas = self.server.area_manager.get_areas() + areas = self.hub.area_manager.get_areas() elif area_id == -2: zone = self.zone_watched if zone is None: @@ -1584,32 +1741,8 @@ def prepare_area_info(self, current_area: AreaManager.Area, area_id: int, return info - def send_area_hdid(self, area_id): - info = self.get_area_hdid(area_id) - self.send_ooc(info) - - def get_area_hdid(self, area_id): - raise NotImplementedError - - def send_all_area_hdid(self): - info = '== HDID List ==' - for i in range(len(self.server.area_manager.get_areas())): - if len(self.server.area_manager.get_areas()[i].clients) > 0: - info += '\r\n{}'.format(self.get_area_hdid(i)) - self.send_ooc(info) - - def send_all_area_ip(self): - info = '== IP List ==' - for i in range(len(self.server.area_manager.get_areas())): - if len(self.server.area_manager.get_areas()[i].clients) > 0: - info += '\r\n{}'.format(self.get_area_ip(i)) - self.send_ooc(info) - - def get_area_ip(self, ip): - raise NotImplementedError - def refresh_char_list(self): - char_list = [0] * len(self.server.character_manager.get_characters()) + char_list = [0] * len(self.hub.character_manager.get_characters()) unusable_ids = self.area.get_chars_unusable(allow_restricted=self.is_staff()) # Remove sneaked players from unusable if needed so that they don't appear as taken # Their characters will not be able to be reused, but at least that's one less clue @@ -1620,25 +1753,25 @@ def refresh_char_list(self): for x in unusable_ids: char_list[x] = -1 - if self.has_character(): + if self.has_participant_character(): char_list[self.char_id] = 0 # Self is always available self.send_command_dict('CharsCheck', { 'chars_status_ao2_list': char_list, }) def refresh_visible_char_list(self): - char_list = [0] * len(self.server.character_manager.get_characters()) + char_list = [0] * len(self.hub.character_manager.get_characters()) unusable_ids = {c.char_id for c in self.get_visible_clients(self.area) - if c.has_character()} + if c.has_participant_character()} if not self.is_staff(): - unusable_ids |= {self.server.character_manager.get_character_id_by_name(name) + unusable_ids |= {self.hub.character_manager.get_character_id_by_name(name) for name in self.area.restricted_chars} for x in unusable_ids: char_list[x] = -1 # Self is always available - if self.has_character(): + if self.has_participant_character(): char_list[self.char_id] = 0 self.send_command_dict('CharsCheck', { 'chars_status_ao2_list': char_list, @@ -1652,10 +1785,6 @@ def send_done(self): self.char_id = -1 # Set to a valid ID if still needed self.send_command_dict('DONE', dict()) - if self.bad_version: - self.send_ooc(f'Unknown client detected {self.version}. ' - f'Assuming standard DRO client protocol.') - def char_select(self): # By running the change_character code, all checks and actions for switching to # spectator are made @@ -1714,7 +1843,7 @@ def login(self, arg: str, auth_command: Callable[[str, Optional[bool]]], role: s # Filter out messages about GMs because they were called earlier in auth_gm if not self.is_gm and announce_to_officers: self.send_ooc_others('{} [{}] logged in as a {}.'.format(self.name, self.id, role), - is_officer=True) + is_officer=True, in_hub=None) logger.log_server('Logged in as a {}.'.format(role), self) if self.area.in_zone and self.area.in_zone != self.zone_watched: @@ -1725,8 +1854,8 @@ def login(self, arg: str, auth_command: Callable[[str, Optional[bool]]], role: s # Send command hints for leading trials and other minigames try: - trial = self.server.trial_manager.get_trial_of_user(self) - except GameError.UserNotPlayerError: + trial = self.hub.trial_manager.get_managee_of_user(self) + except TrialError.UserNotPlayerError: pass else: if self not in trial.get_leaders(): @@ -1746,10 +1875,10 @@ def login(self, arg: str, auth_command: Callable[[str, Optional[bool]]], role: s # No longer bound to AFK rules # Nor lurk callouts - for task in ['as_afk_kick', 'as_lurk']: + for task_name in ['as_afk_kick', 'as_lurk']: try: - self.server.tasker.remove_task(self, [task]) - except KeyError: + self.server.task_manager.delete_task(self, task_name) + except TaskError.TaskNotFoundError: pass # No longer need an IC lock bypass @@ -1758,79 +1887,91 @@ def login(self, arg: str, auth_command: Callable[[str, Optional[bool]]], role: s 'staff member.') self.can_bypass_iclock = False + try: + self.hub.add_leader(self) + except HubError.UserAlreadyLeaderError: + # E.g. logging in as cm after logging in as gm + pass + def auth_mod(self, password: str, announce_to_officers: bool = True): if self.is_mod: raise ClientError('Already logged in.') - if password == self.server.config['modpass']: + if Constants.secure_eq(password, self.server.config['modpass']): self.is_mod = True self.is_cm = False self.is_gm = False - self.in_rp = False else: if announce_to_officers: self.send_ooc_others('{} [{}] failed to login as a moderator.' - .format(self.name, self.id), is_officer=True) + .format(self.name, self.id), is_officer=True, in_hub=None) raise ClientError('Invalid password.') def auth_cm(self, password: str, announce_to_officers: bool = True): if self.is_cm: raise ClientError('Already logged in.') - if password == self.server.config['cmpass']: + if Constants.secure_eq(password, self.server.config['cmpass']): self.is_cm = True self.is_mod = False self.is_gm = False - self.in_rp = False else: if announce_to_officers: self.send_ooc_others('{} [{}] failed to login as a community manager.' - .format(self.name, self.id), is_officer=True) + .format(self.name, self.id), is_officer=True, in_hub=None) raise ClientError('Invalid password.') def auth_gm(self, password: str , announce_to_officers: bool =True): if self.is_gm: raise ClientError('Already logged in.') + hub_pass = self.hub.get_password() # Obtain the daily gm pass (changes at midnight server time, gmpass1=Monday..) current_day = datetime.datetime.today().weekday() daily_gmpass = self.server.config['gmpass{}'.format((current_day % 7) + 1)] - valid_passwords = [self.server.config['gmpass']] + valid_passwords = [hub_pass, self.server.config['gmpass']] if daily_gmpass is not None: valid_passwords.append(daily_gmpass) - if password in valid_passwords: - if password == daily_gmpass: - g_or_daily = 'daily password' - else: - g_or_daily = 'global password' - if announce_to_officers: - self.send_ooc_others('{} [{}] logged in as a game master with the {}.' - .format(self.name, self.id, g_or_daily), is_officer=True) - self.is_gm = True - self.is_mod = False - self.is_cm = False - self.in_rp = False + for valid_password in valid_passwords: + if Constants.secure_eq(password, valid_password): + break else: if announce_to_officers: self.send_ooc_others('{} [{}] failed to login as a game master.' - .format(self.name, self.id), is_officer=True) + .format(self.name, self.id), + is_officer=True, in_hub=None) raise ClientError('Invalid password.') + if password == hub_pass: + password_type = 'hub password' + elif password == daily_gmpass: + password_type = 'daily password' + else: + password_type = 'global password' + + if announce_to_officers: + self.send_ooc_others('{} [{}] logged in as a game master with the {}.' + .format(self.name, self.id, password_type), + is_officer=True, in_hub=None) + self.is_gm = True + self.is_mod = False + self.is_cm = False + def logout(self): self.is_mod = False self.is_gm = False self.is_cm = False # Clean-up operations - if self.server.rp_mode: - self.in_rp = True if self.area.evidence_mod == 'HiddenCM': self.area.broadcast_evidence_list() # Update the music list to show reachable areas and activate the AFK timer self.send_music_list_view() - self.server.tasker.create_task(self, ['as_afk_kick', self.area.afk_delay, - self.area.afk_sendto]) + self.server.task_manager.new_task(self, 'as_afk_kick', { + 'afk_delay': self.area.afk_delay, + 'afk_sendto': self.area.afk_sendto, + }) # If using a character restricted in the area, switch out if self.get_char_name() in self.area.restricted_chars: @@ -1874,8 +2015,8 @@ def logout(self): # If managing a day cycle clock, end it try: - self.server.tasker.remove_task(self, ['as_day_cycle']) - except KeyError: + self.server.task_manager.delete_task(self, 'as_day_cycle') + except TaskError.TaskNotFoundError: pass # If having global IC enabled, remove it @@ -1889,6 +2030,12 @@ def logout(self): f'you are not a spectator.') self.unfollow_user() + try: + self.hub.remove_leader(self) + except HubError.UserNotLeaderError: + # E.g. logging out as gm + pass + def get_hdid(self) -> str: return self.hdid @@ -1896,13 +2043,10 @@ def get_ip(self) -> int: return self.ipid def get_ipreal(self) -> str: - return self.transport.get_extra_info('peername')[0] - - def get_char_name(self, char_id: int = None) -> str: - if char_id is None: - char_id = self.char_id + return Constants.get_ip_of_transport(self.transport) - return self.server.character_manager.get_character_name(char_id) + def get_char_name(self) -> str: + return self.hub.character_manager.get_character_name(self.char_id) def get_showname_history(self) -> str: info = '== Showname history of client {} =='.format(self.id) @@ -2009,7 +2153,7 @@ def get_info(self, as_mod: bool = False, as_cm: bool = False, identifier=None): areas = set() else: start, end = self.multi_ic[0].id, self.multi_ic[1].id - areas = {area for area in self.server.area_manager.get_areas() + areas = {area for area in self.hub.area_manager.get_areas() if start <= area.id <= end} info += ('\n*Global IC range: {}. Global IC prefix: {}' .format(Constants.format_area_ranges(areas), @@ -2081,18 +2225,22 @@ def __lt__(self, other: Any) -> bool: return self.id < other.id def __repr__(self): - return ('C::{}:{}:{}:{}:{}:{}:{}' + return ('C::{}:{}:{}:{}:{}:{}:{}:{}' .format(self.id, self.ipid, self.name, self.get_char_name(), self.showname, - self.is_staff(), self.area.id)) + self.is_staff(), self.area.id, self.hub.get_numerical_id())) - def __init__(self, server: TsuserverDR, client_obj: typing.Type[ClientManager.Client] = None): - if client_obj is None: - client_obj = self.Client + def __init__( + self, + server: TsuserverDR, + default_client_type: Type[ClientManager.Client] = None, + ): + if default_client_type is None: + default_client_type = self.Client - self.clients: Set[client_obj] = set() + self.clients: Set[default_client_type] = set() self.server = server self.cur_id = [False] * self.server.config['playerlimit'] - self.client_obj = client_obj + self.default_client_type = default_client_type # Phantom peek timer stuff base_time = 300 @@ -2117,7 +2265,9 @@ def _phantom_peek(): threshold = (client.paranoia+zone_paranoia)/100 if random.random() < threshold: delay = random.randint(0, int(_phantom_peek_fuzz_per_client)-1) - self.server.tasker.create_task(client, ['as_phantom_peek', delay]) + self.server.task_manager.new_task(client, 'as_phantom_peek', { + 'length': delay, + }) self.phantom_peek_timer.set_max_value( random.randint(int(_phantom_peek_timer_min), int(_phantom_peek_timer_max)) ) @@ -2125,20 +2275,28 @@ def _phantom_peek(): self.phantom_peek_timer._on_max_end = _phantom_peek self.phantom_peek_timer.start() - def new_client(self, transport, client_obj: typing.Type[ClientManager.Client] = None, - protocol=None): - ip = transport.get_extra_info('peername')[0] if transport else "127.0.0.1" + def new_client( + self, + client_type: Type[Client] = None, + hub: _Hub = None, + transport: _ProactorSocketTransport = None, + protocol: AOProtocol = None, + ) -> Tuple[Client, bool]: + if client_type is None: + client_type = self.default_client_type + if hub is None: + hub = self.server.hub_manager.get_default_managee() + + ip = Constants.get_ip_of_transport(transport) ipid = self.server.get_ipid(ip) - if client_obj is None: - client_obj = self.client_obj - cur_id = -1 for i in range(self.server.config['playerlimit']): if not self.cur_id[i]: cur_id = i break - c = client_obj(self.server, transport, cur_id, ipid, protocol=protocol) + + c = client_type(self.server, hub, transport, cur_id, ipid, protocol=protocol) self.clients.add(c) # Check if server is full, and if so, send number of players and disconnect @@ -2149,7 +2307,7 @@ def new_client(self, transport, client_obj: typing.Type[ClientManager.Client] = }) return c, False self.cur_id[cur_id] = True - self.server.tasker.client_tasks[cur_id] = dict() + self.server.task_manager.tasks[c] = dict() return c, True def remove_client(self, client: ClientManager.Client): @@ -2167,8 +2325,8 @@ def remove_client(self, client: ClientManager.Client): if client.id >= 0: # Avoid having pre-clients do this (before they are granted a cID) self.cur_id[client.id] = False # Cancel client's pending tasks - for task_id in self.server.tasker.client_tasks[client.id].copy(): - self.server.tasker.remove_task(client, [task_id]) + for task_name in self.server.task_manager.tasks[client].copy(): + self.server.task_manager.delete_task(client, task_name) # If the client was part of a party, remove them from the party if client.party: @@ -2245,7 +2403,7 @@ def get_targets(self, client: ClientManager.Client, key: TargetType, value: Any, if local: areas = [client.area] else: - areas = client.server.area_manager.get_areas() + areas = client.hub.area_manager.get_areas() targets = [] if key == TargetType.ALL: for nkey in range(8): diff --git a/server/clients.py b/server/clients.py index b365e7426..7161c43e9 100644 --- a/server/clients.py +++ b/server/clients.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -28,7 +29,7 @@ class DefaultDROProtocol(_Singleton): def __eq__(self, other): return type(self).__name__ == type(other).__name__ - VERSION_TO_SEND = [1, 2, 0] + VERSION_TO_SEND = [1, 2, 2] HAS_CLIENTSIDE_MUSIC_LOOPING = False HAS_DISTINCT_AREA_AND_MUSIC_LIST_OUTGOING_PACKETS = True @@ -403,351 +404,13 @@ class ClientDRO1d0d0(DefaultDROProtocol): ] -class ClientDROLegacy(DefaultDROProtocol): - HAS_CLIENTSIDE_MUSIC_LOOPING = False - HAS_DISTINCT_AREA_AND_MUSIC_LIST_OUTGOING_PACKETS = False +class ClientAO2d10(DefaultDROProtocol): HAS_JOINED_AREA = False ALLOWS_REPEATED_MESSAGES_FROM_SAME_CHAR = False ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False ALLOWS_INVISIBLE_BLANKPOSTS = False REPLACES_BASE_OPUS_FOR_MP3 = True - ALLOWS_CHAR_LIST_RELOAD = False - - MS_INBOUND = [ - ('msg_type', ArgType.STR), # 0 - ('pre', ArgType.STR_OR_EMPTY), # 1 - ('folder', ArgType.STR), # 2 - ('anim', ArgType.STR), # 3 - ('text', ArgType.STR), # 4 - ('pos', ArgType.STR), # 5 - ('sfx', ArgType.STR), # 6 - ('anim_type', ArgType.INT), # 7 - ('char_id', ArgType.INT), # 8 - ('sfx_delay', ArgType.INT), # 9 - ('button', ArgType.INT), # 10 - ('evidence', ArgType.INT), # 11 - ('flip', ArgType.INT), # 12 - ('ding', ArgType.INT), # 13 - ('color', ArgType.INT), # 14 - ] - - MS_OUTBOUND = [ - ('msg_type', 1), # 0 - ('pre', '-'), # 1 - ('folder', ''), # 2 - ('anim', '../../misc/blank'), # 3 - ('msg', ''), # 4 - ('pos', 'jud'), # 5 - ('sfx', 0), # 6 - ('anim_type', 0), # 7 - ('char_id', 0), # 8 - ('sfx_delay', 0), # 9 - ('button', 0), # 10 - ('evidence', 0), # 11 - ('flip', 0), # 12 - ('ding', -1), # 13 - ('color', 0), # 14 - ('showname', ' '), # 15 - ] - - MC_INBOUND = [ - ('name', ArgType.STR), # 0 - ('char_id', ArgType.INT), # 1 - ] - - MC_OUTBOUND = [ - ('name', ''), # 0 - ('char_id', -1), # 1 - ] - - BN_OUTBOUND = [ - ('name', ''), # 0 - ] - - -class ClientAO2d6(DefaultDROProtocol): - HAS_CLIENTSIDE_MUSIC_LOOPING = False - HAS_DISTINCT_AREA_AND_MUSIC_LIST_OUTGOING_PACKETS = False - HAS_JOINED_AREA = False - ALLOWS_REPEATED_MESSAGES_FROM_SAME_CHAR = False - ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False - ALLOWS_INVISIBLE_BLANKPOSTS = False - REPLACES_BASE_OPUS_FOR_MP3 = True - ALLOWS_CHAR_LIST_RELOAD = False - - MS_INBOUND = [ - ('msg_type', ArgType.STR), # 0 - ('pre', ArgType.STR_OR_EMPTY), # 1 - ('folder', ArgType.STR), # 2 - ('anim', ArgType.STR), # 3 - ('text', ArgType.STR), # 4 - ('pos', ArgType.STR), # 5 - ('sfx', ArgType.STR), # 6 - ('anim_type', ArgType.INT), # 7 - ('char_id', ArgType.INT), # 8 - ('sfx_delay', ArgType.INT), # 9 - ('button', ArgType.INT), # 10 - ('evidence', ArgType.INT), # 11 - ('flip', ArgType.INT), # 12 - ('ding', ArgType.INT), # 13 - ('color', ArgType.INT), # 14 - ('showname', ArgType.STR_OR_EMPTY), # 15 - ('charid_pair', ArgType.INT), # 16 - ('offset_pair', ArgType.INT), # 17 - ('nonint_pre', ArgType.INT), # 18 - ] - - MS_OUTBOUND = [ - ('msg_type', 1), # 0 - ('pre', '-'), # 1 - ('folder', ''), # 2 - ('anim', '../../misc/blank'), # 3 - ('msg', ''), # 4 - ('pos', 'jud'), # 5 - ('sfx', 0), # 6 - ('anim_type', 0), # 7 - ('char_id', 0), # 8 - ('sfx_delay', 0), # 9 - ('button', 0), # 10 - ('evidence', 0), # 11 - ('flip', 0), # 12 - ('ding', -1), # 13 - ('color', 0), # 14 - ('showname', ' '), # 15 - ('charid_pair', -1), # 16 - ('other_folder', ''), # 17 - ('other_emote', ''), # 18 - ('offset_pair', 0), # 19 - ('other_offset', 0), # 20 - ('other_flip', 0), # 21 - ('nonint_pre', 0), # 22 - ] - - MC_INBOUND = [ - ('name', ArgType.STR), # 0 - ('char_id', ArgType.INT), # 1 - ('showname', ArgType.STR_OR_EMPTY), # 2 - ] - - MC_OUTBOUND = [ - ('name', ''), # 0 - ('char_id', -1), # 1 - ('showname', ''), # 2 - ] - - BN_OUTBOUND = [ - ('name', ''), # 0 - ] - - -class ClientAO2d7(DefaultDROProtocol): - HAS_CLIENTSIDE_MUSIC_LOOPING = False - HAS_DISTINCT_AREA_AND_MUSIC_LIST_OUTGOING_PACKETS = False - HAS_JOINED_AREA = False - ALLOWS_REPEATED_MESSAGES_FROM_SAME_CHAR = False - ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False - ALLOWS_INVISIBLE_BLANKPOSTS = False - REPLACES_BASE_OPUS_FOR_MP3 = True - ALLOWS_CHAR_LIST_RELOAD = False - - MS_INBOUND = [ - ('msg_type', ArgType.STR), # 0 - ('pre', ArgType.STR_OR_EMPTY), # 1 - ('folder', ArgType.STR), # 2 - ('anim', ArgType.STR), # 3 - ('text', ArgType.STR), # 4 - ('pos', ArgType.STR), # 5 - ('sfx', ArgType.STR), # 6 - ('anim_type', ArgType.INT), # 7 - ('char_id', ArgType.INT), # 8 - ('sfx_delay', ArgType.INT), # 9 - ('button', ArgType.INT), # 10 - ('evidence', ArgType.INT), # 11 - ('flip', ArgType.INT), # 12 - ('ding', ArgType.INT), # 13 - ('color', ArgType.INT), # 14 - ('showname', ArgType.STR_OR_EMPTY), # 15 - ('charid_pair', ArgType.INT), # 16 - ('offset_pair', ArgType.INT), # 17 - ('nonint_pre', ArgType.INT), # 18 - ('looping_sfx', ArgType.INT), # 19 - ('screenshake', ArgType.INT), # 20 - ('frame_screenshake', ArgType.STR_OR_EMPTY), # 21 - ('frame_realization', ArgType.STR_OR_EMPTY), # 22 - ('frame_sfx', ArgType.STR_OR_EMPTY), # 23 - ] - - MS_OUTBOUND = [ - ('msg_type', 1), # 0 - ('pre', '-'), # 1 - ('folder', ''), # 2 - ('anim', '../../misc/blank'), # 3 - ('msg', ''), # 4 - ('pos', 'jud'), # 5 - ('sfx', 0), # 6 - ('anim_type', 0), # 7 - ('char_id', 0), # 8 - ('sfx_delay', 0), # 9 - ('button', 0), # 10 - ('evidence', 0), # 11 - ('flip', 0), # 12 - ('ding', -1), # 13 - ('color', 0), # 14 - ('showname', ''), # 15 - ('charid_pair', -1), # 16 - ('other_folder', ''), # 17 - ('other_emote', ''), # 18 - ('offset_pair', 0), # 19 - ('other_offset', 0), # 20 - ('other_flip', 0), # 21 - ('nonint_pre', 0), # 22 - ('looping_sfx', 0), # 23 - ('screenshake', 0), # 24 - ('frame_screenshake', ''), # 25 - ('frame_realization', ''), # 26 - ('frame_sfx', ''), # 27 - ] - - MC_INBOUND = [ - ('name', ArgType.STR), # 0 - ('char_id', ArgType.INT), # 1 - ('showname', ArgType.STR_OR_EMPTY), # 2 - ] - - MC_OUTBOUND = [ - ('name', ''), # 0 - ('char_id', -1), # 1 - ('showname', ''), # 2 - ] - - BN_OUTBOUND = [ - ('name', ''), # 0 - ] - - FA_OUTBOUND = [ - ('areas_ao2_list', list()), # 0 - ] - - -class ClientAO2d8d4(DefaultDROProtocol): - HAS_JOINED_AREA = False - ALLOWS_REPEATED_MESSAGES_FROM_SAME_CHAR = False - ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False - ALLOWS_INVISIBLE_BLANKPOSTS = False - REPLACES_BASE_OPUS_FOR_MP3 = True - ALLOWS_CHAR_LIST_RELOAD = False - - MS_INBOUND = [ - ('msg_type', ArgType.STR), # 0 - ('pre', ArgType.STR_OR_EMPTY), # 1 - ('folder', ArgType.STR), # 2 - ('anim', ArgType.STR), # 3 - ('text', ArgType.STR), # 4 - ('pos', ArgType.STR), # 5 - ('sfx', ArgType.STR), # 6 - ('anim_type', ArgType.INT), # 7 - ('char_id', ArgType.INT), # 8 - ('sfx_delay', ArgType.INT), # 9 - ('button', ArgType.INT), # 10 - ('evidence', ArgType.INT), # 11 - ('flip', ArgType.INT), # 12 - ('ding', ArgType.INT), # 13 - ('color', ArgType.INT), # 14 - ('showname', ArgType.STR_OR_EMPTY), # 15 - ('charid_pair_pair_order', ArgType.STR), # 16 - ('offset_pair', ArgType.INT), # 17 - ('nonint_pre', ArgType.INT), # 18 - ('looping_sfx', ArgType.INT), # 19 - ('screenshake', ArgType.INT), # 20 - ('frame_screenshake', ArgType.STR_OR_EMPTY), # 21 - ('frame_realization', ArgType.STR_OR_EMPTY), # 22 - ('frame_sfx', ArgType.STR_OR_EMPTY), # 23 - ('additive', ArgType.INT), # 24 - ('effect', ArgType.STR), # 25 - ] - - MS_OUTBOUND = [ - ('msg_type', 1), # 0 - ('pre', '-'), # 1 - ('folder', ''), # 2 - ('anim', '../../misc/blank'), # 3 - ('msg', ''), # 4 - ('pos', 'jud'), # 5 - ('sfx', 0), # 6 - ('anim_type', 0), # 7 - ('char_id', 0), # 8 - ('sfx_delay', 0), # 9 - ('button', 0), # 10 - ('evidence', 0), # 11 - ('flip', 0), # 12 - ('ding', -1), # 13 - ('color', 0), # 14 - ('showname', ''), # 15 - ('charid_pair_pair_order', -1), # 16 - ('other_folder', ''), # 17 - ('other_emote', ''), # 18 - ('offset_pair', 0), # 19 - ('other_offset', 0), # 20 - ('other_flip', 0), # 21 - ('nonint_pre', 0), # 22 - ('looping_sfx', 0), # 23 - ('screenshake', 0), # 24 - ('frame_screenshake', ''), # 25 - ('frame_realization', ''), # 26 - ('frame_sfx', ''), # 27 - ('additive', 0), # 28 - ('effect', ''), # 29 - ] - - MC_INBOUND = [ - ('name', ArgType.STR), # 0 - ('char_id', ArgType.INT), # 1 - ('showname', ArgType.STR_OR_EMPTY), # 2 - ('effects', ArgType.INT), # 3 - ] - - MC_OUTBOUND = [ - ('name', ''), # 0 - ('char_id', -1), # 1 - ('showname', ''), # 2 - ('loop', 1), # 3 - ('channel', 0), # 4 - ('effects', 0), # 5 - ] - - BN_OUTBOUND = [ - ('name', ''), # 0 - ('pos', ''), # 1 - ] - - -class ClientAO2d9d0(DefaultDROProtocol): - HAS_JOINED_AREA = False - ALLOWS_REPEATED_MESSAGES_FROM_SAME_CHAR = False - ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False - ALLOWS_INVISIBLE_BLANKPOSTS = False - REPLACES_BASE_OPUS_FOR_MP3 = True - ALLOWS_CHAR_LIST_RELOAD = False - - ASKCHAA_INBOUND = [ - ('ao290doesnotsupportpacketswithnoarguments', ArgType.STR_OR_EMPTY), # 0 - ] - - RC_INBOUND = [ - ('ao290doesnotsupportpacketswithnoarguments', ArgType.STR_OR_EMPTY), # 0 - ] - - RM_INBOUND = [ - ('ao290doesnotsupportpacketswithnoarguments', ArgType.STR_OR_EMPTY), # 0 - ] - - RD_INBOUND = [ - ('ao290doesnotsupportpacketswithnoarguments', ArgType.STR_OR_EMPTY), # 0 - ] - - ZZ_INBOUND = [ - ('ao290doesnotsupportpacketswithnoarguments', ArgType.STR_OR_EMPTY), # 0 - ] + ALLOWS_CHAR_LIST_RELOAD = True MS_INBOUND = [ ('msg_type', ArgType.STR), # 0 @@ -834,33 +497,4 @@ class ClientAO2d9d0(DefaultDROProtocol): PW_INBOUND = [ ('password', ArgType.STR_OR_EMPTY), # 0 - ] - - -class ClientAO2d10(ClientAO2d9d0): - HAS_JOINED_AREA = False - ALLOWS_REPEATED_MESSAGES_FROM_SAME_CHAR = False - ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False - ALLOWS_INVISIBLE_BLANKPOSTS = False - REPLACES_BASE_OPUS_FOR_MP3 = True - ALLOWS_CHAR_LIST_RELOAD = False - - ASKCHAA_INBOUND = [] - - RC_INBOUND = [] - - RM_INBOUND = [] - - RD_INBOUND = [] - - ZZ_INBOUND = [] - - -class ClientKFO2d8(ClientAO2d7): - pass - -class ClientCC22(ClientAO2d6): - pass - -class ClientCC24(ClientAO2d8d4): - pass + ] diff --git a/server/commands.py b/server/commands.py index 4a725da92..401ed1e2d 100644 --- a/server/commands.py +++ b/server/commands.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -24,14 +25,15 @@ import hashlib import string import time -import traceback from server import logger from server.constants import Constants, TargetType -from server.exceptions import ArgumentError, AreaError, ClientError, MusicError, ServerError +from server.exceptions import ArgumentError, AreaError, ClientError, HubError, MusicError, ServerError, TaskError from server.exceptions import PartyError, ZoneError, TrialError, NonStopDebateError from server.client_manager import ClientManager +from typing import Union + # : required parameter # {parameter_name}: optional parameter @@ -39,6 +41,95 @@ # (OFFICER ONLY): need to be logged in as CM or mod +def ooc_cmd_ambient(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Sets up the ambient sound effect of the current area. Players in the current area, and players + that later join the area, will be ordered to play the area ambient sound effect. + + SYNTAX + /ambient + + PARAMETERS + : Name of the ambient sound effect + + EXAMPLES + >>> /ambient wind.wav + Sets the ambient sound effect of the area to `wind.wav`. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='>0') + + client.area.ambient = arg + + for target in client.area.clients: + target.send_area_ambient(name=arg) + + client.send_ooc(f'You have set the ambient sound effect of your area to `{arg}`.') + client.send_ooc_others(f'The ambient sound effect of your area was set to `{arg}`.', + in_area=True, is_zstaff_flex=False) + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] set the ambient sound effect ' + f'of their area to `{arg}` ({client.area.id}).', is_zstaff_flex=True) + + +def ooc_cmd_ambient_end(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Clears the ambient sound effect of the current area. Players in the current area will be ordered + to stop playing the former area ambient sound effect, and players that later join the area will + not play the former area ambient sound effect. + Returns an error if no ambient sound effect is playing in the area. + + SYNTAX + /ambient + + PARAMETERS + None + + EXAMPLES + >>> /ambient_end + Clears the ambient sound effect of the area. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + + if not client.area.ambient: + raise ClientError('There already is no ambient sound effect in your area.') + + client.area.ambient = '' + + for target in client.area.clients: + target.send_area_ambient(name='') + + client.send_ooc('You have cleared the ambient sound effect of your area.') + client.send_ooc_others('The ambient sound effect of your area was cleared.', in_area=True, + is_zstaff_flex=False) + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] cleared the ambient sound ' + f'effect of their area ({client.area.id}).', is_zstaff_flex=True) + + +def ooc_cmd_ambient_info(client: ClientManager.Client, arg: str): + """ + Displays the current area ambient sound effect. + Returns an error if no area ambient sound effect is playing. + + SYNTAX + /ambient_info + + PARAMETERS + None + + EXAMPLES + Assuming the ambient sound effect of the current area is `wind.wav`... + >>> /ambient_info + Returns 'The current ambient sound effect of your area is `wind.wav`'. + """ + Constants.assert_command(client, arg, parameters='=0') + + if not client.area.ambient: + raise ClientError('There already is no ambient sound effect in your area.') + + client.send_ooc(f'The current ambient sound effect of your area is `{client.area.ambient}`.') + + def ooc_cmd_announce(client: ClientManager.Client, arg: str): """ (MOD ONLY) Sends an "announcement" to all users in the server, regardless of whether they have global chat @@ -69,22 +160,23 @@ def ooc_cmd_announce(client: ClientManager.Client, arg: str): def ooc_cmd_area(client: ClientManager.Client, arg: str): """ - Either lists all areas in the server or changes your area to a new given area. - Returns an error if you are unathorized to list all areas or unable to move to the intended new - area. + Either lists all areas in the hub or changes your area to a new given area. + Returns an error if you are unathorized to list all areas, already in the new area, or + unable to move to the intended new area. SYNTAX - /area {new_area_id} + /area + /area PARAMETERS None - OPTIONAL PARAMETERS - {new_area_id}: ID of the area + PARAMETERS + : ID of the area EXAMPLES >>> /area - Lists all areas in the server along with their user count. + Lists all areas in the hub. >>> /area 1 Moves you to area 1. """ @@ -100,105 +192,23 @@ def ooc_cmd_area(client: ClientManager.Client, arg: str): if not client.server.config['announce_areas'] and not client.is_staff(): raise ClientError('You must be authorized to use the no-parameter version of this ' 'command.') - if client.in_rp: - client.send_limited_area_list() - else: - client.send_area_list() + client.send_limited_area_list() # Switch to new area else: try: - area = client.server.area_manager.get_area_by_id(int(args[0])) + area = client.hub.area_manager.get_area_by_id(int(args[0])) except ValueError: raise ArgumentError('Area ID must be a number.') client.change_area(area, from_party=(client.party is not None)) -def ooc_cmd_area_kick(client: ClientManager.Client, arg: str): - """ (STAFF ONLY+VARYING REQUIREMENTS) - Kicks a user by client ID or IPID to a given area by area ID or name, or the default area if not - given an area. GMs cannot perform this command on users in lobby areas. - If given IPID, it will kick all clients you opened. Otherwise, it will just kick the given user. - Search by IPID can only be performed by CMs and mods. - Returns an error if the given identifier does not correspond to a user, or if there was some - sort of error in the process of kicking the user to the area (e.g. full area). - - SYNTAX - /area_kick {target_area} - /area_kick {target_area} - - PARAMETERS - : Client identifier (number in brackets in /getarea) - : IPID for the client (number in parentheses in /getarea) - - OPTIONAL PARAMETERS - {target_area}: Intended area to kick the user, by area ID or name - - EXAMPLES - Assuming the default area of the server is area 0... - >>> /area_kick 1 - Kicks the user with client ID 1 to area 0. - >>> /area_kick 1234567890 3 - Kicks all the clients opened by the user with IPID 1234567890 to area 3. - >>> /area_kick 0987654321 Lobby - Kicks all the clients opened by the user with IPID 0987654321 to Lobby. - >>> /area_kick 3 Class Trial Room,\ 2 - Kicks the user with client ID 1 to Class Trial Room, 2 (note the ,\). - """ - - Constants.assert_command(client, arg, is_staff=True, parameters='&1-2', split_spaces=True) - - arg = arg.split(' ') - - if client.area.lobby_area and not client.is_officer(): - raise ClientError('You must be authorized to kick clients in lobby areas.') - - if len(arg) == 1: - area = client.server.area_manager.get_area_by_id(client.server.default_area) - else: - area = Constants.parse_area_names(client, [" ".join(arg[1:])])[0] - - for c in Constants.parse_id_or_ipid(client, arg[0]): - # Failsafe in case kicked player has their character changed due to its character being used - current_char = c.displayname - old_area = c.area - - try: - c.change_area(area, override_passages=True, override_effects=True, ignore_bleeding=True, - ignore_autopass=True) - except ClientError as error: - error_mes = ", ".join([str(s) for s in error.args]) - client.send_ooc('Unable to kick client {} ({}) to area {}: {}' - .format(c.id, current_char, area.id, error_mes)) - else: - client.send_ooc('You kicked client {} ({}) from area {} to area {}.' - .format(c.id, current_char, old_area.id, area.id)) - c.send_ooc('You were kicked from the area to area {}.'.format(area.id)) - client.send_ooc_others('(X) {} [{}] kicked client {} from area {} to area {}.' - .format(client.displayname, client.id, c.id, old_area.id, - area.id), - not_to={c}, is_staff=True) - - if old_area.is_locked or old_area.is_modlocked: - try: # Try and remove the IPID from the area's invite list - old_area.invite_list.pop(c.ipid) - except KeyError: - # only happens if target joined the locked area through mod powers - pass - - if client.party: - party = client.party - party.remove_member(client) - client.send_ooc('You were also kicked off your party.') - for member in party.get_members(): - member.send_ooc('{} was area kicked off your party.'.format(current_char)) - - def ooc_cmd_area_list(client: ClientManager.Client, arg: str): - """ (OFFICER ONLY) - Sets the server's current area list (what areas exist at any given time). If given no arguments, - it will return the area list to its original value (in areas.yaml). The list of area lists can - be accessed with /area_lists. Clients that do not process 'SM' packets can be in servers that + """ (STAFF ONLY) + Sets the area list of your current hub (what areas exist at any given time). + If given no arguments, it will return the area list to its original value + (in config/areas.yaml). + Clients that do not process 'SM' packets can be in servers that use this command without crashing, but they will continue to only see the areas they could see when joining. Returns an error if the given area list was not found. @@ -216,18 +226,18 @@ def ooc_cmd_area_list(client: ClientManager.Client, arg: str): Reset the area list to its original value. """ - Constants.assert_command(client, arg, is_officer=True) + Constants.assert_command(client, arg, is_staff=True) # lists which areas are locked before the reload - old_locked_areas = [area.name for area in client.server.area_manager.get_areas() + old_locked_areas = [area.name for area in client.hub.area_manager.get_areas() if area.is_locked] - client.server.area_manager.command_list_load(client, arg) + client.hub.area_manager.command_list_load(client, arg) # Every area that was locked before the reload gets warned that their areas were unlocked. for area_name in old_locked_areas: try: - area = client.server.area_manager.get_area_by_name(area_name) + area = client.hub.area_manager.get_area_by_name(area_name) area.broadcast_ooc('This area became unlocked after the area reload. Relock it using ' '/lock.') # if no area is found with that name, then an old locked area does not exist anymore, so @@ -238,7 +248,7 @@ def ooc_cmd_area_list(client: ClientManager.Client, arg: str): # Every area that was locked before the reload gets warned that their areas were unlocked. for area_name in old_locked_areas: try: - area = client.server.area_manager.get_area_by_name(area_name) + area = client.hub.area_manager.get_area_by_name(area_name) area.broadcast_ooc('This area became unlocked after the area reload. Relock it using ' '/lock.') # if no area is found with that name, then an old locked area does not exist anymore, so @@ -247,33 +257,73 @@ def ooc_cmd_area_list(client: ClientManager.Client, arg: str): pass -def ooc_cmd_area_lists(client: ClientManager.Client, arg: str): - """ (OFFICER ONLY) - Lists all available area lists as established in config/area_lists.yaml. Note that, as this - file is updated independently from the other area lists, an area list does not need to be in - this file in order to be usable, and an area list in this list may no longer exist. +def ooc_cmd_area_list_info(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Returns the area list of your current hub. SYNTAX - /area_lists + /area_list_info PARAMETERS None EXAMPLES - >>> /area_lists - Return all available area lists. + >>> /area_list_info + May return something like this: + | $H: The current area list is the custom list `beach`. """ Constants.assert_command(client, arg, is_officer=True, parameters='=0') - try: - with Constants.fopen('config/area_lists.yaml', 'r', encoding='utf-8') as f: - output = 'Available area lists:\n' - for line in f: - output += '*{}'.format(line) - client.send_ooc(output) - except ServerError.FileNotFoundError: - raise ClientError('Server file area_lists.yaml not found.') + client.hub.area_manager.command_list_info(client) + + +def ooc_cmd_autoglance(client: ClientManager.Client, arg: str): + """ (VARYING REQUIREMENTS) + Toggles look messages being activated automatically or not to whenever you move, + or (STAFF ONLY) when a target by client ID moves. + + SYNTAX + /autoglance + /autoglance + + PARAMETERS + : Client identifier (number in brackets in /getarea) + + EXAMPLES + Assuming /autoglance for you and for client 1 is off... + >>> /autoglance + Turns autoglance on. + >>> /autoglance + Turns autoglance off. + >>> /autoglance 1 + Turns autoglance for client 1 on. + >>> /autoglance 1 + Turns autoglance for client 1 off. + """ + + Constants.assert_command(client, arg, parameters='<2') + if arg and not client.is_staff(): + raise ClientError.UnauthorizedError('You must be authorized to use the one-parameter ' + 'version of this command.') + if arg: + target = Constants.parse_id(client, arg) + else: + target = client + + target.autoglance = not target.autoglance + status = {False: 'off', True: 'on'} + + if client == target: + client.send_ooc(f'You turned {status[client.autoglance]} your autoglance.') + else: + client.send_ooc(f'You turned {status[target.autoglance]} the autoglance for ' + f'{target.displayname} [{target.id}].') + target.send_ooc(f'Your autoglance was turned {status[target.autoglance]}.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] turned ' + f'{status[target.autoglance]} the autoglance for ' + f'{target.displayname} [{target.id}] ({client.area.id}).', + is_zstaff_flex=True, not_to={target}) def ooc_cmd_autopass(client: ClientManager.Client, arg: str): @@ -285,12 +335,13 @@ def ooc_cmd_autopass(client: ClientManager.Client, arg: str): SYNTAX /autopass + /autopass PARAMETERS - None + : Client identifier (number in brackets in /getarea) EXAMPLES - Assuming /autopass for you and for client 1is off... + Assuming /autopass for you and for client 1 is off... >>> /autopass Turns autopass on. >>> /autopass @@ -367,18 +418,19 @@ def ooc_cmd_ban(client: ClientManager.Client, arg: str): if targets: for c in targets: client.send_ooc('You banned {} [{}/{}].'.format(c.displayname, c.ipid, c.hdid)) - client.send_ooc_others('{} was banned.'.format(c.displayname), is_officer=False, - in_area=True) + client.send_ooc_others('{} was banned.'.format(c.displayname), + is_officer=False, in_area=True, in_hub=True) client.send_ooc_others('{} [{}] banned {} [{}/{}].' .format(client.name, client.id, c.displayname, c.ipid, c.hdid), - is_officer=True) + is_officer=True, in_hub=None) c.disconnect() plural = 's were' if len(targets) != 1 else ' was' client.send_ooc('You banned `{}`. As a result, {} client{} kicked as well.' .format(idnt, len(targets), plural)) client.send_ooc_others('{} banned `{}`. As a result, {} client{} kicked as well.' - .format(client.name, idnt, len(targets), plural), is_officer=True) + .format(client.name, idnt, len(targets), plural), + is_officer=True, in_hub=None) logger.log_server('Banned {}.'.format(idnt), client) @@ -428,10 +480,10 @@ def ooc_cmd_banhdid(client: ClientManager.Client, arg: str): for c in targets: client.send_ooc('You HDID banned {} [{}/{}].'.format(c.displayname, c.ipid, c.hdid)) client.send_ooc_others('{} was banned.'.format(c.displayname), is_officer=False, - in_area=True) + in_area=True, in_hub=True) client.send_ooc_others('{} [{}] HDID banned {} [{}/{}].' .format(client.name, client.id, c.displayname, c.ipid, c.hdid), - is_officer=True) + is_officer=True, in_hub=None) c.disconnect() plural = 's were' if len(targets) != 1 else ' was' @@ -439,7 +491,7 @@ def ooc_cmd_banhdid(client: ClientManager.Client, arg: str): .format(arg, len(targets), plural)) client.send_ooc_others('{} [{}] banned HDID `{}`. As a result, {} client{} kicked as well.' .format(client.name, client.id, arg, len(targets), plural), - is_officer=True) + is_officer=True, in_hub=None) logger.log_server('HDID-banned {}.'.format(identifier), client) @@ -474,6 +526,55 @@ def ooc_cmd_bg(client: ClientManager.Client, arg: str): .format(client.area.id, client.get_char_name(), arg), client) +def ooc_cmd_bg_list(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Sets the background list of your current hub (what backgrounds areas may normally use at any + given time). + If given no arguments, it will return the background list to its original value + (in config/backgrounds.yaml). + Returns an error if the given background list name included relative directories, + was not found, caused an OS error when loading, or raised a YAML or asset syntax error when + loading. + + SYNTAX + /bg_list + + PARAMETERS + : Name of the intended background list + + EXAMPLES + >>> /bg_list beach + Load the "beach" background list. + >>> /bg_list + Reset the background list to its original value. + """ + + Constants.assert_command(client, arg, is_staff=True) + + client.hub.background_manager.command_list_load(client, arg) + + +def ooc_cmd_bg_list_info(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Returns the background list of your current hub. + + SYNTAX + /bg_list_info + + PARAMETERS + None + + EXAMPLES + >>> /bg_list_info + May return something like this: + | $H: The current background list is the custom list `custom`. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + + client.hub.background_manager.command_list_info(client) + + def ooc_cmd_bglock(client: ClientManager.Client, arg: str): """ (MOD ONLY) Toggles background changes by non-mods in the current area being allowed/disallowed. @@ -501,28 +602,94 @@ def ooc_cmd_bglock(client: ClientManager.Client, arg: str): .format(client.area.id, client.get_char_name(), client.area.bg_lock), client) -def ooc_cmd_bilock(client: ClientManager.Client, arg: str): - """ (VARYING REQUIREMENTS) - Changes the passage status between given areas by name or ID. Passages are unidirectional, so - to change a passage in just one direction, use /unilock instead. - If given one area, it will change the passage status between the current area and the given one. - If given two areas instead, it will change the passage status between them (but requires - staff role to use). - Returns an error if you are unauthorized to create new passages or change existing ones in - any of the relevant areas. In particular, non-staff members are not allowed to create passages - that did not exist when the areas were loaded or that a staff member did not create before. +def ooc_cmd_bg_period(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Changes the background of the current area associated with the given period. + Returns an error if area background is locked and you are unathorized or if the sought + background does not exist. SYNTAX - /bilock - /bilock , + /bg_period PARAMETERS - : Area whose passage status with the current area will be changed. - : Area whose passage status with will be changed. - : Area whose passage status with will be changed. + : Period name + : New background name, possibly with spaces (e.g. Principal's Room) EXAMPLES - Assuming you are in area 0 when executing these commands and originally the only existing + >>> /bg_period night Beach (night) + Changes background to Beach (night) whenever the area has a night period active. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='>1') + if not client.is_mod and client.area.bg_lock: + raise AreaError("This area's background is locked.") + + args = arg.split() + tod_name = args[0] + bg_name = ' '.join(args[1:]) + + client.area.change_background_tod(bg_name, tod_name, validate=False) + client.send_ooc(f'You changed the background associated with period `{tod_name}` to ' + f'`{bg_name}`.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] changed the background ' + f'associated with period `{tod_name}` to `{bg_name}`.', + is_zstaff_flex=True) + logger.log_server('[{}][{}]Changed background associated with period `{}` to {}' + .format(client.area.id, client.get_char_name(), tod_name, bg_name), client) + + +def ooc_cmd_bg_period_end(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Removes the background of the current area associated with the given period + Returns an error if area background is locked and you are unathorized or if the sought + background does not exist. + + SYNTAX + /bg_period_end + + PARAMETERS + : Period name + + EXAMPLES + >>> /bg_period_end night + Removes the background associated with the night period of the current area. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='=1') + if not client.is_mod and client.area.bg_lock: + raise AreaError("This area's background is locked.") + + client.area.change_background_tod('', arg, validate=False) + client.send_ooc(f'You removed the background associated with period `{arg}`.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] removed the background ' + f'associated with period `{arg}`.', + is_zstaff_flex=True) + logger.log_server('[{}][{}]Removed background associated with period `{}`' + .format(client.area.id, client.get_char_name(), arg), client) + + +def ooc_cmd_bilock(client: ClientManager.Client, arg: str): + """ (VARYING REQUIREMENTS) + Changes the passage status between given areas by name or ID. Passages are unidirectional, so + to change a passage in just one direction, use /unilock instead. + If given one area, it will change the passage status between the current area and the given one. + If given two areas instead, it will change the passage status between them (but requires + staff role to use). + Returns an error if you are unauthorized to create new passages or change existing ones in + any of the relevant areas. In particular, non-staff members are not allowed to create passages + that did not exist when the areas were loaded or that a staff member did not create before. + + SYNTAX + /bilock + /bilock , + + PARAMETERS + : Area whose passage status with the current area will be changed. + : Area whose passage status with will be changed. + : Area whose passage status with will be changed. + + EXAMPLES + Assuming you are in area 0 when executing these commands and originally the only existing passage lock is from area 1 'Class Trial Room' to area 2 'Class Trial Room, 2'... >>> /bilock Class Trial Room Locks the passage between area 0 and Class Trial Room. @@ -542,7 +709,7 @@ def ooc_cmd_bilock(client: ClientManager.Client, arg: str): areas = Constants.parse_two_area_names(client, areas, area_duplicate=False, check_valid_range=False) - now_reachable = client.server.area_manager.change_passage_lock(client, areas, bilock=True, + now_reachable = client.hub.area_manager.change_passage_lock(client, areas, bilock=True, change_passage_visibility=False) status = {True: 'unlocked', False: 'locked'} @@ -601,7 +768,7 @@ def ooc_cmd_bilockh(client: ClientManager.Client, arg: str): areas = Constants.parse_two_area_names(client, arg.split(', '), area_duplicate=False, check_valid_range=False) - now_reachable = client.server.area_manager.change_passage_lock(client, areas, bilock=True, + now_reachable = client.hub.area_manager.change_passage_lock(client, areas, bilock=True, change_passage_visibility=True) status = {True: 'unlocked and revealed', False: 'locked and hid'} @@ -916,7 +1083,7 @@ def ooc_cmd_bloodtrail_list(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') # Get all areas with blood in them - areas = sorted([area for area in client.server.area_manager.get_areas() + areas = sorted([area for area in client.hub.area_manager.get_areas() if len(area.bleeds_to) > 0 or area.blood_smeared], key=lambda x: x.name) @@ -1166,7 +1333,7 @@ def ooc_cmd_can_rollp(client: ClientManager.Client, arg: str): def ooc_cmd_can_rpgetarea(client: ClientManager.Client, arg: str): """ (STAFF ONLY) - Toggles users subject to RP mode being able/unable to use /getarea in the current area. + Toggles users being able/unable to use /getarea in the current area. SYNTAX /can_rpgetarea @@ -1175,7 +1342,7 @@ def ooc_cmd_can_rpgetarea(client: ClientManager.Client, arg: str): None EXAMPLES - Assuming the current area is currently allowing /getarea in RP mode... + Assuming the current area is currently allowing /getarea... >>> /can_rpgetarea Non-staff members can no longer use /getarea. >>> /can_rpgetarea @@ -1196,7 +1363,7 @@ def ooc_cmd_can_rpgetarea(client: ClientManager.Client, arg: str): def ooc_cmd_can_rpgetareas(client: ClientManager.Client, arg: str): """ (STAFF ONLY) - Toggles users subject to RP mode being able/unable to use /getareas in the current area. + Toggles users being able/unable to use /getareas in the current area. SYNTAX /can_rpgetareas @@ -1205,7 +1372,7 @@ def ooc_cmd_can_rpgetareas(client: ClientManager.Client, arg: str): None EXAMPLES - Assuming the current area is currently allowing /getareas in RP mode... + Assuming the current area is currently allowing /getareas... >>> /can_rpgetareas Non-staff members can no longer use /getareas. >>> /can_rpgetareas @@ -1224,6 +1391,55 @@ def ooc_cmd_can_rpgetareas(client: ClientManager.Client, arg: str): status[client.area.rp_getareas_allowed].capitalize()), client) +def ooc_cmd_char_list(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Sets the current character list of your current hub (what characters a player may use at any + given time). + If given no arguments, it will return the character list to its original value + (in config/characters.yaml). + Returns an error if the given character list name included relative directories, + was not found, caused an OS error when loading, or raised a YAML or asset syntax error when + loading. + + SYNTAX + /char_list + + PARAMETERS + : Name of the intended character list + + EXAMPLES + >>> /char_list Transylvania + Load the "Transylvania" character list. + >>> /char_list + Reset the character list to its original value. + """ + + Constants.assert_command(client, arg, is_staff=True) + + client.hub.character_manager.command_list_load(client, arg) + + +def ooc_cmd_char_list_info(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Returns the character list of your current hub. + + SYNTAX + /char_list_info + + PARAMETERS + None + + EXAMPLES + >>> /char_list_info + May return something like this: + | $H: The current character list is the custom list `custom`. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + + client.hub.character_manager.command_list_info(client) + + def ooc_cmd_charlog(client: ClientManager.Client, arg: str): """ (STAFF ONLY) List all character details a user by client ID or IPID has had during the session. @@ -1296,7 +1512,7 @@ def ooc_cmd_charselect(client: ClientManager.Client, arg: str): c.send_ooc('You were forced to open the character select screen.') client.send_ooc_others(f'{client.name} [{client.id}] forced {c.displayname} [{c.id}] ' f'to open the character select screen ({c.area.id}).', - not_to={c}, is_officer=True) + not_to={c}, is_officer=True, in_hub=None) c.char_select() @@ -1325,7 +1541,7 @@ def ooc_cmd_char_restrict(client: ClientManager.Client, arg: str): except ArgumentError: raise ArgumentError('This command takes one character name.') - if not client.server.character_manager.is_character(arg): + if not client.hub.character_manager.is_character(arg): raise ArgumentError('Unrecognized character folder name: {}'.format(arg)) status = {True: 'enabled', False: 'disabled'} @@ -1456,8 +1672,7 @@ def ooc_cmd_cleardoc(client: ClientManager.Client, arg: str): def ooc_cmd_cleargm(client: ClientManager.Client, arg: str): """ (OFFICER ONLY) - Logs out a game master by client ID or all game masters in the server if not given an ID and puts - them in RP mode if needed. + Logs out a game master by client ID or all game masters in the server if not given an ID. Returns an error if the given identifier does not correspond to a user, if given a target they are already not a GM, or if no GMs are currently logged in. @@ -1497,14 +1712,16 @@ def ooc_cmd_cleargm(client: ClientManager.Client, arg: str): raise ClientError(f'Client {target.id} is already not a GM.') client.send_ooc(f'You have logged out client {client.id} from their GM rank.') client.send_ooc_others(f'{client.name} [{client.id}] has logged out {target.name} ' - f'[{target.id}] from their GM rank.', is_officer=True) + f'[{target.id}] from their GM rank.', + is_officer=True, in_hub=None) else: if not gm_list: raise ClientError('No GMs are currently connected.') output = Constants.cjoin(gm_list, sort=False) client.send_ooc(f'You have logged out the following clients from their GM rank: {output}.') - client.send_ooc_others(f'{client.name} [{client.id}] has been logged out these clients ' - f'from their GM rank: {output}.', is_officer=True) + client.send_ooc_others(f'{client.name} [{client.id}] has logged out these clients ' + f'from their GM rank: {output}.', + is_officer=True, in_hub=None) def ooc_cmd_clock(client: ClientManager.Client, arg: str): @@ -1572,14 +1789,10 @@ def ooc_cmd_clock(client: ClientManager.Client, arg: str): raise ArgumentError(f'Invalid hour start {pre_hour_start}.') # Code after this assumes input is validated - try: - client.server.tasker.get_task(client, ['as_day_cycle']) - except KeyError: - normie_notif = True - else: - # Already existing day cycle. Will overwrite preexisting one - # But first, make sure normies do not get a new notification. - normie_notif = False + + # If already existing day cycle. Will overwrite preexisting one + # But first, make sure normies do not get a new notification. + normie_notif = not client.server.task_manager.is_task(client, 'as_day_cycle') client.send_ooc(f'You initiated a day cycle of length {hour_length} seconds per hour in areas ' f'{area_1} through {area_2}. The cycle ID is {client.id}.') @@ -1591,8 +1804,14 @@ def ooc_cmd_clock(client: ClientManager.Client, arg: str): client.send_ooc_others(f'{client.displayname} initiated a day cycle.', is_zstaff_flex=False, pred=lambda c: area_1 <= c.area.id <= area_2) - client.server.tasker.create_task(client, ['as_day_cycle', time.time(), area_1, area_2, - hour_length, hour_start, hours_in_day, normie_notif]) + client.server.task_manager.new_task(client, 'as_day_cycle', { + 'area_1': area_1, + 'area_2': area_2, + 'hour_length': hour_length, + 'hour_start': hour_start, + 'hours_in_day': hours_in_day, + 'send_first_hour': normie_notif, + }) def ooc_cmd_clock_end(client: ClientManager.Client, arg: str): @@ -1625,8 +1844,8 @@ def ooc_cmd_clock_end(client: ClientManager.Client, arg: str): raise ArgumentError('Client {} is not online.'.format(arg)) try: - client.server.tasker.remove_task(c, ['as_day_cycle']) - except KeyError: + client.server.task_manager.delete_task(c, 'as_day_cycle') + except TaskError.TaskNotFoundError: raise ClientError('Client {} has not initiated any day cycles.'.format(arg)) @@ -1662,17 +1881,17 @@ def ooc_cmd_clock_pause(client: ClientManager.Client, arg: str): raise ArgumentError('Client {} is not online.'.format(arg)) try: - task = client.server.tasker.get_task(c, ['as_day_cycle']) - except KeyError: + task = client.server.task_manager.get_task(c, 'as_day_cycle') + except TaskError.TaskNotFoundError: raise ClientError('Client {} has not initiated any day cycles.'.format(arg)) - if client.server.tasker.get_task_attr(c, ['as_day_cycle'], 'is_unknown'): + if task.parameters['is_unknown']: raise ClientError('You may not pause the day cycle while the time is unknown.') - if client.server.tasker.get_task_attr(c, ['as_day_cycle'], 'is_paused'): + if task.parameters['is_paused']: raise ClientError('Day cycle is already paused.') - client.server.tasker.set_task_attr(c, ['as_day_cycle'], 'refresh_reason', 'pause') - client.server.tasker.cancel_task(task) + task.parameters['refresh_reason'] = 'pause' + client.server.task_manager.force_asyncio_cancelled_error(task) def ooc_cmd_clock_period(client: ClientManager.Client, arg: str): @@ -1711,13 +1930,13 @@ def ooc_cmd_clock_period(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='&2-3') try: - task = client.server.tasker.get_task(client, ['as_day_cycle']) - except KeyError: + task = client.server.task_manager.get_task(client, 'as_day_cycle') + except TaskError.TaskNotFoundError: raise ClientError('You have not initiated any day cycles.') args = arg.split() - hour_length = client.server.tasker.get_task_attr(client, ['as_day_cycle'], 'main_hour_length') - hours_in_day = client.server.tasker.get_task_attr(client, ['as_day_cycle'], 'hours_in_day') + hour_length = task.parameters['main_hour_length'] + hours_in_day = task.parameters['hours_in_day'] name = args[0].lower() pre_hour_start = args[2] if len(args) == 3 else args[1] @@ -1737,10 +1956,9 @@ def ooc_cmd_clock_period(client: ClientManager.Client, arg: str): except ValueError: raise ArgumentError(f'Invalid period hour length {pre_hour_length}.') - client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'new_period_start', - (hour_start, name, hour_length)) - client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', 'period') - client.server.tasker.cancel_task(task) + task.parameters['new_period_start'] = (hour_start, name, hour_length) + task.parameters['refresh_reason'] = 'period' + client.server.task_manager.force_asyncio_cancelled_error(task) def ooc_cmd_clock_period_end(client: ClientManager.Client, arg: str): @@ -1766,14 +1984,13 @@ def ooc_cmd_clock_period_end(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=1') try: - task = client.server.tasker.get_task(client, ['as_day_cycle']) - except KeyError: + task = client.server.task_manager.get_task(client, 'as_day_cycle') + except TaskError.TaskNotFoundError: raise ClientError('You have not initiated any day cycles.') - client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'new_period_start', - (-1, arg, 0)) - client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', 'period') - client.server.tasker.cancel_task(task) + task.parameters['new_period_start'] = (-1, arg, 0) + task.parameters['refresh_reason'] = 'period' + client.server.task_manager.force_asyncio_cancelled_error(task) def ooc_cmd_clock_set(client: ClientManager.Client, arg: str): @@ -1803,8 +2020,8 @@ def ooc_cmd_clock_set(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=2') try: - task = client.server.tasker.get_task(client, ['as_day_cycle']) - except KeyError: + task = client.server.task_manager.get_task(client, 'as_day_cycle') + except TaskError.TaskNotFoundError: raise ClientError('You have not initiated any day cycles.') pre_hour_length, pre_hour_start = arg.split(' ') @@ -1817,16 +2034,15 @@ def ooc_cmd_clock_set(client: ClientManager.Client, arg: str): try: hour_start = int(pre_hour_start) - hours_in_day = client.server.tasker.get_task_attr(client, ['as_day_cycle'], 'hours_in_day') + hours_in_day = task.parameters['hours_in_day'] if hour_start < 0 or hour_start >= hours_in_day: raise ValueError except ValueError: raise ArgumentError('Invalid hour start {}.'.format(pre_hour_start)) - client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'new_day_cycle_args', - (hour_length, hour_start)) - client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', 'set') - client.server.tasker.cancel_task(task) + task.parameters['new_day_cycle_args'] = (hour_length, hour_start) + task.parameters['refresh_reason'] = 'set' + client.server.task_manager.force_asyncio_cancelled_error(task) def ooc_cmd_clock_set_hours(client: ClientManager.Client, arg: str): @@ -1855,8 +2071,8 @@ def ooc_cmd_clock_set_hours(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=1') try: - task = client.server.tasker.get_task(client, ['as_day_cycle']) - except KeyError: + task = client.server.task_manager.get_task(client, 'as_day_cycle') + except TaskError.TaskNotFoundError: raise ClientError('You have not initiated any day cycles.') try: @@ -1866,9 +2082,9 @@ def ooc_cmd_clock_set_hours(client: ClientManager.Client, arg: str): except ValueError: raise ArgumentError(f'Invalid number of hours per day {hours_in_day}.') - client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'hours_in_day', hours_in_day) - client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', 'set_hours') - client.server.tasker.cancel_task(task) + task.parameters['hours_in_day'] = hours_in_day + task.parameters['refresh_reason'] = 'set_hours' + client.server.task_manager.force_asyncio_cancelled_error(task) def ooc_cmd_clock_unknown(client: ClientManager.Client, arg: str): @@ -1892,15 +2108,15 @@ def ooc_cmd_clock_unknown(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - task = client.server.tasker.get_task(client, ['as_day_cycle']) - except KeyError: + task = client.server.task_manager.get_task(client, 'as_day_cycle') + except TaskError.TaskNotFoundError: raise ClientError('You have not initiated any day cycles.') - if client.server.tasker.get_task_attr(client, ['as_day_cycle'], 'is_unknown'): + if task.parameters['is_unknown']: raise ClientError('Your day cycle already has unknown time.') - client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', 'unknown') - client.server.tasker.cancel_task(task) + task.parameters['refresh_reason'] = 'unknown' + client.server.task_manager.force_asyncio_cancelled_error(task) def ooc_cmd_clock_unpause(client: ClientManager.Client, arg: str): @@ -1935,17 +2151,17 @@ def ooc_cmd_clock_unpause(client: ClientManager.Client, arg: str): raise ArgumentError('Client {} is not online.'.format(arg)) try: - task = client.server.tasker.get_task(c, ['as_day_cycle']) - except KeyError: + task = client.server.task_manager.get_task(c, 'as_day_cycle') + except TaskError.TaskNotFoundError: raise ClientError('Client {} has not initiated any day cycles.'.format(arg)) - if client.server.tasker.get_task_attr(c, ['as_day_cycle'], 'is_unknown'): + if task.parameters['is_unknown']: raise ClientError('You may not unpause the day cycle while the time is unknown.') - if not client.server.tasker.get_task_attr(c, ['as_day_cycle'], 'is_paused'): + if not task.parameters['is_paused']: raise ClientError('Day cycle is already unpaused.') - client.server.tasker.set_task_attr(c, ['as_day_cycle'], 'refresh_reason', 'unpause') - client.server.tasker.cancel_task(task) + task.parameters['refresh_reason'] = 'unpause' + client.server.task_manager.force_asyncio_cancelled_error(task) def ooc_cmd_coinflip(client: ClientManager.Client, arg: str): @@ -2021,8 +2237,8 @@ def ooc_cmd_cure(client: ClientManager.Client, arg: str): for effect in sorted_effects: # Check if the client is subject to a countdown for that effect try: - client.server.tasker.remove_task(target, [effect.async_name]) - except KeyError: + client.server.task_manager.delete_task(target, effect.async_name) + except TaskError.TaskNotFoundError: pass # Do nothing if not subject to one if target != client: @@ -2125,7 +2341,7 @@ def ooc_cmd_deafen(client: ClientManager.Client, arg: str): def ooc_cmd_defaultarea(client: ClientManager.Client, arg: str): """ (MOD ONLY) - Set the default area by area ID for all future clients to join when connecting to the server. + Set the default area by area ID for all future clients to join when joining your hub. Returns an error if the area ID is invalid. SYNTAX @@ -2142,14 +2358,14 @@ def ooc_cmd_defaultarea(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_mod=True, parameters='=1') try: - client.server.area_manager.get_area_by_id(int(arg)) + area = client.hub.area_manager.get_area_by_id(int(arg)) except ValueError: raise ArgumentError('Expected numerical value for area ID.') except AreaError: raise ClientError('ID {} does not correspond to a valid area ID.'.format(arg)) - client.server.default_area = int(arg) - client.send_ooc('Set default area to {}.'.format(arg)) + client.hub.area_manager.set_default_area(area) + client.send_ooc('Set default area of your hub to {}.'.format(arg)) def ooc_cmd_dicelog(client: ClientManager.Client, arg: str): @@ -2299,6 +2515,63 @@ def ooc_cmd_disemvowel(client: ClientManager.Client, arg: str): client.area.broadcast_ooc("{} was disemvowelled.".format(c.displayname)) +def ooc_cmd_dj_list(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Sets the current DJ list of your current hub (what music list a player will see when joining an + area of your hub if they do not have a personal music list active). + If given no arguments, it will return the DJ list to its original value + (in config/music.yaml). + Returns an error if the given music list name included relative directories, + was not found, caused an OS error when loading, or raised a YAML or asset syntax error when + loading. + + SYNTAX + /dj_list + + PARAMETERS + : Name of the intended music list to serve as DJ list. + + EXAMPLES + >>> /dj_list trial + Load the "trial" DJ list. + >>> /dj_list + Reset the DJ list to its original value. + """ + + Constants.assert_command(client, arg, is_staff=True) + + client.hub.music_manager.command_list_load(client, arg) + + for target in client.hub.get_players(): + if target.music_manager.is_default_file_loaded(): + target.send_ooc('As you had no personal music list loaded, you will be shown the hub ' + 'music list.') + target.send_music_list_view() + else: + target.send_ooc('As you had a personal music list loaded, you will not be shown the ' + 'hub music list. Display the hub music list by running /music_list.') + +def ooc_cmd_dj_list_info(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Returns the DJ list of your current hub. + + SYNTAX + /dj_list_info + + PARAMETERS + None + + EXAMPLES + >>> /dj_list_info + May return something like this: + | $H: The current DJ list is the custom list `trial`. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + + client.hub.music_manager.command_list_info(client) + + def ooc_cmd_doc(client: ClientManager.Client, arg: str): """ Returns the area's current doc link, or sets it to a new one. @@ -2528,11 +2801,13 @@ def ooc_cmd_files_set(client: ClientManager.Client, arg: str): def ooc_cmd_follow(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) + """ (VARYING REQUIREMENTS) Starts following a user by their client ID. When the target area moves area, you will follow - them automatically except if disallowed by the new area. + them automatically except if disallowed by the new area. You must be using a non-participant + character to follow another user, or (STAFF ONLY) may use any character to follow another user. Requires /unfollow to undo. - Returns an error if you are part of a party. + Returns an error if you are part of a party or you are using a participant character with + insufficient permissions. SYNTAX /follow @@ -2549,8 +2824,9 @@ def ooc_cmd_follow(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=1') except ClientError.UnauthorizedError: Constants.assert_command(client, arg, parameters='=1') - if client.has_character(): - raise ClientError('You must be authorized to follow while having a character.') + if client.has_participant_character(): + raise ClientError('You must be authorized to follow while having a participant ' + 'character.') if client.party: raise PartyError('You cannot follow someone while in a party.') @@ -2647,7 +2923,7 @@ def ooc_cmd_getarea(client: ClientManager.Client, arg: str): Lists the characters (and associated client IDs) in the current area OR (STAFF ONLY) lists the character (and associated client IDs) in the given area by area ID or name. - Returns an error if you are subject to RP mode and is in an area that disables /getarea, if + Returns an error if you are in an area that disables /getarea, if you are blind and not staff, or if the given identifier does not correspond to an area. SYNTAX @@ -2687,8 +2963,8 @@ def ooc_cmd_getarea(client: ClientManager.Client, arg: str): def ooc_cmd_getareas(client: ClientManager.Client, arg: str): """ List the characters (and associated client IDs) in each area. - Returns an error if you are subject to RP mode and is in an area that disables /getareas, - or if they are blind and not staff. + Returns an error if you are in an area that disables /getareas, + or if you are blind and not staff. SYNTAX /getareas @@ -2719,7 +2995,7 @@ def ooc_cmd_gimp(client: ClientManager.Client, arg: str): """ (MOD ONLY) Gimps all IC messages of a user by client ID (number in brackets) or IPID (number in parentheses). In particular, their message will be replaced by one of the messages listed in - Constants.gimp_message in Constants.py. If given IPID, it will affect all clients opened by the + config/gimp.yaml. If given IPID, it will affect all clients opened by the user. Otherwise, it will just affect the given client. Requires /ungimp to undo. Returns an error if the given identifier does not correspond to a user. @@ -2752,10 +3028,9 @@ def ooc_cmd_globalic(client: ClientManager.Client, arg: str): Send client's subsequent IC messages to users only in specified areas. Can take either area IDs or area names. If you are not in intended destination range, it will NOT send messages to your area. Requires /unglobalic to undo. - Returns an error if the given identifier does not correspond to an area. - If given two areas, it will send the IC messages to all areas between the given ones inclusive. If given one area, it will send the IC messages only to the given area. + Returns an error if the given identifier does not correspond to an area. SYNTAX /globalic @@ -2852,10 +3127,11 @@ def ooc_cmd_glock(client: ClientManager.Client, arg: str): client.send_ooc('You have {} the global chat.'.format(status[client.server.global_allowed])) client.send_ooc_others('A mod has {} the global chat.' - .format(status[client.server.global_allowed]), is_officer=False) + .format(status[client.server.global_allowed]), + is_officer=False, in_hub=None) client.send_ooc_others('{} [{}] has {} the global chat.' .format(client.name, client.id, status[client.server.global_allowed]), - is_officer=True) + is_officer=True, in_hub=None) logger.log_server('{} has {} the global chat.' .format(client.name, status[client.server.global_allowed]), client) @@ -2891,37 +3167,6 @@ def ooc_cmd_gm(client: ClientManager.Client, arg: str): .format(client.area.id, client.get_char_name(), arg), client) -def ooc_cmd_gmlock(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) - Sets the current area as accessible only to staff members. Players in the area at the time of - the lock will be able to leave and return to the area, regardless of authorization. - Requires /unlock to undo. - Returns an error if the area is already gm-locked or if the area is set to be unlockable. - - SYNTAX - /gmlock - - PARAMETERS - None - - EXAMPLE - >>> /gmlock - Sets the current area as accessible only to staff members. - """ - - Constants.assert_command(client, arg, is_staff=True, parameters='=0') - - if not client.area.locking_allowed: - raise ClientError('Area locking is disabled in this area.') - if client.area.is_gmlocked: - raise ClientError('Area is already gm-locked.') - - client.area.is_gmlocked = True - client.area.broadcast_ooc('Area gm-locked.') - for i in client.area.clients: - client.area.invite_list[i.ipid] = None - - def ooc_cmd_gmself(client: ClientManager.Client, arg: str): """ (STAFF ONLY) Makes all opened multiclients login as game master without them needing to put in a GM password. @@ -3262,79 +3507,349 @@ def ooc_cmd_help_more(client: ClientManager.Client, arg: str): client.send_ooc(output.replace('\r\n\r\n', '\r\n').strip()) -def ooc_cmd_iclock(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) - Toggles IC messages by non-staff or players without IC lock bypass in the current area being - allowed/disallowed. If now disallowed, any user with an active IC lock bypass will lose it. - Returns an error if a GM attempts to lock IC in an area where such an action is forbidden. +def ooc_cmd_hub(client: ClientManager.Client, arg: str): + """ + Either lists all hubs in the server or changes your area to a new given area. + Returns an error if you are already in the target hub or you are unable to move to the default + area of the new hub. SYNTAX - /iclock + /hub + /hub PARAMETERS - None + : Numerical ID of the hub EXAMPLES - Assuming the area starts with IC lock off... - >>> /iclock - Turns IC lock on. - >>> /iclock - Turns IC lock off. + >>> /hub + Lists all hubs in the server. + >>> /hub 1 + Moves you to hub 1. """ - Constants.assert_command(client, arg, is_staff=True, parameters='=0') - if not client.is_officer() and (client.is_gm and not client.area.gm_iclock_allowed): - raise ClientError('GMs are not authorized to change IC locks in this area.') - - client.area.ic_lock = not client.area.ic_lock - status = {True: 'locked', False: 'unlocked'} - - client.send_ooc('You {} the IC chat in this area.'.format(status[client.area.ic_lock])) - client.send_ooc_others(f'The IC chat has been {status[client.area.ic_lock]} in this area.' - .format(), is_zstaff_flex=False, in_area=True) - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] has ' - f'{status[client.area.ic_lock]} the IC chat in area {client.area.name} ' - f'({client.area.id}).', is_zstaff_flex=True) + Constants.assert_command(client, arg, parameters='<2') - logger.log_server('[{}][{}]Changed IC lock to {}' - .format(client.area.id, client.get_char_name(), client.area.ic_lock), client) + args = arg.split() + # List all hubs + if not args: + client.send_limited_hub_list() - if not client.area.ic_lock: - # Remove ic lock bypasses - affected_players = list() - for player in client.area.clients: - if player.can_bypass_iclock and not player.is_staff(): - affected_players.append(player) + # Switch to new area + else: + try: + numerical_id = int(args[0]) + except ValueError: + raise ArgumentError('Hub ID must be a number.') - if affected_players: - for player in affected_players: - player.send_ooc('You have lost your IC lock bypass as the IC chat in ' - 'your area has been unlocked.') - player.send_ooc_others(f'(X) {player.displayname} [{player.id}] has lost their IC ' - f'lock bypass as the IC chat in their area has ' - f'been unlocked ({client.area.id}).', - is_zstaff_flex=client.area) - player.can_bypass_iclock = False + try: + hub = client.hub.manager.get_managee_by_numerical_id(numerical_id) + except HubError.ManagerInvalidGameIDError: + raise HubError.ManagerInvalidGameIDError('Hub not found.') + client.change_hub(hub, from_party=(client.party is not None)) -def ooc_cmd_iclock_bypass(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) - Provides a non-staff player permission to talk in their current area if the area is IC locked. - Returns an error if the given identifier does not correspond to a user, if the target is - already staff or if the IC chat in the area of the target is not locked. +def ooc_cmd_hub_create(client: ClientManager.Client, arg: str): + """ (OFFICER ONLY) + Creates a new hub with the given name, or with a default generated name if not given one. + The numerical ID of the hub will be the lowest non-taken numerical hub ID. SYNTAX - /iclock_bypass + /hub_create {name} PARAMETERS - : Client identifier (number in brackets in /getarea) + None + + OPTIONAL PARAMETERS + {name}: Name of the hub EXAMPLES - Assuming user with user with client ID 1 starts without a bypass... - >>> /iclock_bypass 1 - Grants that user an IC lock bypass - >>> /iclock_bypass 1 - Revokes that user of their IC lock bypass + Assuming that two hubs with numerical IDs 0 and 2 respectively exist... + >>> /hub_create + Creates hub with numerical ID 1. + >>> /hub_create hubby hub + Creates hub with numerical ID 3 and name "hubby hub". + """ + + Constants.assert_command(client, arg, is_officer=True) + + hub = client.hub.manager.new_managee() + if arg: + hub.set_name(arg) + + for target in client.server.get_clients(): + target.send_music_list_view() + + if arg: + client.send_ooc(f'You created hub {hub.get_numerical_id()} with name {hub.get_name()}.') + client.send_ooc_others(f'{client.name} [{client.id}] created hub {hub.get_numerical_id()} ' + f'with name {hub.get_name()}.', is_officer=True, in_hub=None) + else: + client.send_ooc(f'You created hub {hub.get_numerical_id()}.') + client.send_ooc_others(f'{client.name} [{client.id}] created hub {hub.get_numerical_id()}.', + is_officer=True, in_hub=None) + +def ooc_cmd_hub_end(client: ClientManager.Client, arg: str): + """ (VARYING REQUIREMENTS) + (STAFF ONLY) Deletes the current hub if not given a numerical ID, or + (OFFICER ONLY) of the given hub by numerical ID. + Players in the deleted hub are moved to the default hub of the server. + Returns an error if given a numerical ID and it is not the numerical ID of a hub in the server, + or if the server has only one hub. + + SYNTAX + /hub_end + /hub_end + + PARAMETERS + : Numerical ID + + EXAMPLES + >>> /hub_end + Deletes the current hub. + >>> /hub_end 2 + Deletes the hub with numerical ID 2. + """ + + try: + Constants.assert_command(client, arg, is_officer=True, parameters='<2') + except ClientError.UnauthorizedError: + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + + if not arg: + arg = client.hub.get_numerical_id() + + try: + hub = client.hub.manager.get_managee_by_numerical_id(arg) + except HubError.ManagerInvalidGameIDError: + raise ClientError(f'Hub {arg} not found.') + + try: + client.hub.manager.delete_managee(hub) + except HubError.ManagerCannotManageeNoManagees: + raise ClientError(f'You cannot delete a hub when it is the only one of the server.') + + for target in client.server.get_clients(): + target.send_music_list_view() + + client.send_ooc(f'You deleted hub {hub.get_numerical_id()}.') + client.send_ooc_others(f'{client.name} [{client.id}] deleted hub {hub.get_numerical_id()}.', + is_officer=True, in_hub=None) + + +def ooc_cmd_hub_info(client: ClientManager.Client, arg: str): + """ (VARYING REQUIREMENTS) + (STAFF ONLY) Return information about the current hub if not given a numerical ID, or + (OFFICER ONLY) of the given hub by numerical ID. + Returns an error if given a numerical ID and it is not the numerical ID of a hub in the server. + + SYNTAX + /hub_info + /hub_info + + PARAMETERS + : Numerical ID + + EXAMPLES + >>> /hub_info + May return something like this: + | [17:34] $H: == Hub 0 == + | *GMs: 1. NonGMs: 0 + | *Area list: config/areas.yaml + | *Background list: config/bg_lists/beach.yaml + | *Character list: config/char_lists/custom.yaml + | *DJ list: config/music.yaml + """ + + try: + Constants.assert_command(client, arg, is_officer=True, parameters='<2') + except ClientError.UnauthorizedError: + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + + if not arg: + arg = client.hub.get_numerical_id() + + try: + hub = client.hub.manager.get_managee_by_numerical_id(arg) + except HubError.ManagerInvalidGameIDError: + raise ClientError(f'Hub {arg} not found.') + + info = hub.get_info() + client.send_ooc(info) + + +def ooc_cmd_hub_password(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Changes the hub password. + + SYNTAX + /hub_password + + PARAMETERS + : New password + + EXAMPLES + >>> /hub_password 11037 + Sets the hub password to 11037. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='>0') + + client.hub.set_password(arg) + client.send_ooc('You have changed the password of your hub.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] changed the password of your ' + f'hub. Do /hub_password_info to retrieve it.', + is_zstaff_flex=True, is_officer=False) + hid = client.hub.get_numerical_id() + client.send_ooc_others(f'{client.name} [{client.id}] changed the password of hub {hid}. Do ' + f'/hub_password_info {hid} to retrieve it.', + is_officer=True, in_hub=None) + + +def ooc_cmd_hub_password_info(client: ClientManager.Client, arg: str): + """ (VARYING REQUIREMENTS) + (STAFF ONLY) Gets the password of the current hub or, (OFFICER ONLY) the given hub by numerical + ID. + Returns an error if given a numerical ID and it is not the numerical ID of a hub in the server. + + SYNTAX + /hub_password_info + /hub_password_info + + PARAMETERS + : Numerical ID + + EXAMPLES + >>> /hub_password_info + May return something like this: + | $H: The hub password is `2124`. + """ + + try: + Constants.assert_command(client, arg, is_officer=True, parameters='<2') + except ClientError.UnauthorizedError: + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + + if not arg: + arg = client.hub.get_numerical_id() + + try: + hub = client.hub.manager.get_managee_by_numerical_id(arg) + except HubError.ManagerInvalidGameIDError: + raise ClientError(f'Hub {arg} not found.') + + password = hub.get_password() + client.send_ooc(f'The hub password is `{password}`.') + + +def ooc_cmd_hub_rename(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Changes the name of a hub by its numerical ID if given a name, or clears it if not given one. + + SYNTAX + /hub_rename + /hub_rename + + PARAMETERS + : Name + + EXAMPLES + >>> /hub_rename Great Hub + Changes the name of the hub to Great Hub. + >>> /hub_rename + Clears the name of the hub. + """ + + Constants.assert_command(client, arg, is_staff=True) + + hub = client.hub + hub.set_name(arg) + + if arg: + client.send_ooc(f'You have renamed your hub to `{arg}`.') + client.send_ooc_others(f'{client.displayname} [{client.id}] renamed your hub to `{arg}` ' + f'({client.area.id}).', is_zstaff_flex=True) + else: + client.send_ooc('You have cleared the name of your hub.') + client.send_ooc_others(f'{client.displayname} [{client.id}] cleared the name of your hub ' + f'({client.area.id}).', is_zstaff_flex=True) + + for target in client.server.get_clients(): + target.send_music_list_view() + + +def ooc_cmd_iclock(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Toggles IC messages by non-staff or players without IC lock bypass in the current area being + allowed/disallowed. If now disallowed, any user with an active IC lock bypass will lose it. + Returns an error if a GM attempts to lock IC in an area where such an action is forbidden. + + SYNTAX + /iclock + + PARAMETERS + None + + EXAMPLES + Assuming the area starts with IC lock off... + >>> /iclock + Turns IC lock on. + >>> /iclock + Turns IC lock off. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + if not client.is_officer() and (client.is_gm and not client.area.gm_iclock_allowed): + raise ClientError('GMs are not authorized to change IC locks in this area.') + + client.area.ic_lock = not client.area.ic_lock + status = {True: 'locked', False: 'unlocked'} + + client.send_ooc('You {} the IC chat in this area.'.format(status[client.area.ic_lock])) + client.send_ooc_others(f'The IC chat has been {status[client.area.ic_lock]} in this area.' + .format(), is_zstaff_flex=False, in_area=True) + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] has ' + f'{status[client.area.ic_lock]} the IC chat in area {client.area.name} ' + f'({client.area.id}).', is_zstaff_flex=True) + + logger.log_server('[{}][{}]Changed IC lock to {}' + .format(client.area.id, client.get_char_name(), client.area.ic_lock), client) + + if not client.area.ic_lock: + # Remove ic lock bypasses + affected_players = list() + for player in client.area.clients: + if player.can_bypass_iclock and not player.is_staff(): + affected_players.append(player) + + if affected_players: + for player in affected_players: + player.send_ooc('You have lost your IC lock bypass as the IC chat in ' + 'your area has been unlocked.') + player.send_ooc_others(f'(X) {player.displayname} [{player.id}] has lost their IC ' + f'lock bypass as the IC chat in their area has ' + f'been unlocked ({client.area.id}).', + is_zstaff_flex=client.area) + player.can_bypass_iclock = False + + +def ooc_cmd_iclock_bypass(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Provides a non-staff player permission to talk in their current area if the area is IC locked. + Returns an error if the given identifier does not correspond to a user, if the target is + already staff or if the IC chat in the area of the target is not locked. + + SYNTAX + /iclock_bypass + + PARAMETERS + : Client identifier (number in brackets in /getarea) + + EXAMPLES + Assuming user with user with client ID 1 starts without a bypass... + >>> /iclock_bypass 1 + Grants that user an IC lock bypass + >>> /iclock_bypass 1 + Revokes that user of their IC lock bypass """ Constants.assert_command(client, arg, is_staff=True, parameters='=1') @@ -3425,7 +3940,7 @@ def ooc_cmd_invite(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, parameters='>0') - if not client.area.is_locked and not client.area.is_modlocked and not client.area.is_gmlocked: + if not client.area.is_locked and not client.area.is_modlocked: raise ClientError('Area is not locked.') targets = list() # Start with empty list @@ -3526,11 +4041,11 @@ def ooc_cmd_kick(client: ClientManager.Client, arg: str): # Kick matching targets for c in Constants.parse_id_or_ipid(client, arg): client.send_ooc('You kicked {} [{}/{}].'.format(c.displayname, c.ipid, c.hdid)) - client.send_ooc_others('{} was kicked.'.format(c.displayname), is_officer=False, - in_area=True) + client.send_ooc_others('{} was kicked.'.format(c.displayname), + is_officer=False, in_area=True, in_hub=True) client.send_ooc_others('{} [{}] kicked {} [{}/{}].' .format(client.name, client.id, c.displayname, c.ipid, c.hdid), - is_officer=True) + is_officer=True, in_hub=None) logger.log_server('Kicked {}.'.format(c.ipid), client) c.disconnect() @@ -3593,10 +4108,10 @@ def ooc_cmd_knock(client: ClientManager.Client, arg: str): # Get area by either name or ID try: - target_area = client.server.area_manager.get_area_by_name(arg) + target_area = client.hub.area_manager.get_area_by_name(arg) except AreaError: try: - target_area = client.server.area_manager.get_area_by_id(int(arg)) + target_area = client.hub.area_manager.get_area_by_id(int(arg)) except Exception: raise ArgumentError('Could not parse area name {}.'.format(arg)) @@ -3872,7 +4387,7 @@ def ooc_cmd_logingm(client: ClientManager.Client, arg: str): /logingm PARAMETERS - : Game master password, found in config/config.yaml + : Game master password, found via /hub_password_info or in config/config.yaml EXAMPLES >>> /logingm GM @@ -3884,7 +4399,7 @@ def ooc_cmd_logingm(client: ClientManager.Client, arg: str): def ooc_cmd_logout(client: ClientManager.Client, arg: str): """ (STAFF ONLY) - Logs you out from all staff roles and puts you in RP mode if needed. + Logs you out from all staff roles. SYNTAX /logout @@ -3908,7 +4423,8 @@ def ooc_cmd_logout(client: ClientManager.Client, arg: str): client.send_ooc('You are no longer logged in.') client.send_ooc_others('{} [{}] is no longer a {}.' - .format(client.name, client.id, role), is_officer=True) + .format(client.name, client.id, role), + is_officer=True, in_hub=None) client.logout() @@ -3950,7 +4466,7 @@ def ooc_cmd_look(client: ClientManager.Client, arg: str): else: msg += f'You look at {target.displayname} and note this: {target.status}' else: - _, area_description, player_description = client.area.get_look_output_for(client) + _, _, area_description, _, player_description = client.area.get_look_output_for(client) msg += ( f'=== Look results for {client.area.name} ===\r\n' @@ -4042,7 +4558,7 @@ def ooc_cmd_look_list(client: ClientManager.Client, arg: str): info = '== Areas in this server with custom descriptions ==' # Get all areas with changed descriptions - areas = [area for area in client.server.area_manager.get_areas() + areas = [area for area in client.hub.area_manager.get_areas() if area.description != area.default_description] # No areas found means there are no areas with changed descriptions @@ -4231,7 +4747,54 @@ def ooc_cmd_make_gm(client: ClientManager.Client, arg: str): announce_to_officers=False) client.send_ooc('Logged client {} as a GM.'.format(target.id)) client.send_ooc_others('{} [{}] has been logged in as a game master by {} [{}].' - .format(target.name, target.id, client.name, client.id), is_officer=True) + .format(target.name, target.id, client.name, client.id), + is_officer=True, in_hub=None) + + +def ooc_cmd_mindreader(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Toggles a client by ID being a mind reader or not (i.e. can read all thoughts caused by /think, + not just those initiated by the player), or yourself if not given an argument. + Returns an error if the given identifier does not correspond to a user. + + SYNTAX + /mindreader + /mindreader + + OPTIONAL PARAMETERS + {client_id}: Client identifier (number in brackets in /getarea) + + EXAMPLE + Assuming a user with client ID 0 starts as not being a mind reader... + >>> /mindreader 0 + This user can now read all thoughts. + >>> /mindreader 0 + This user can no longer read thoughts not initiated by the user. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='<2') + + # Invert current mindreader status of matching targets + if not arg: + target = client + else: + target = Constants.parse_id(client, arg) + target.is_mindreader = not target.is_mindreader + + status = {False: 'no longer', True: 'now'} + status2 = {False: 'no longer a', True: 'a'} + if client != target: + client.send_ooc(f'{target.displayname} ({target.id}) is {status[target.is_mindreader]} a ' + f'mind reader.') + client.send_ooc_others(f'(X) {client.displayname} ({client.id}) made {target.displayname} ' + f'({target.id}) be {status2[target.is_mindreader]} mind reader ' + f'({client.area.id}).', is_zstaff_flex=True) + target.send_ooc(f'You are {status[target.is_transient]} a mind reader.') + else: + client.send_ooc(f'You made yourself be {status2[target.is_mindreader]} mind reader.') + client.send_ooc_others(f'(X) {client.displayname} ({client.id}) made themselves be ' + f'{status2[target.is_mindreader]} mind reader ' + f'({client.area.id}).', is_zstaff_flex=True) def ooc_cmd_minimap(client: ClientManager.Client, arg: str): @@ -4261,13 +4824,13 @@ def ooc_cmd_minimap(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, parameters='=0') info = '== Minimap for {} =='.format(client.area.name) - if client.area.visible_areas == client.server.area_manager.area_names: + if client.area.visible_areas == client.hub.area_manager.area_names: # Useful abbreviation info += '\r\n' else: # Get all reachable areas and sort them by area ID sorted_areas = sorted(client.area.visible_areas, - key=lambda name: client.server.area_manager.get_area_by_name(name).id) + key=lambda name: client.hub.area_manager.get_area_by_name(name).id) # No areas found or just the current area found means there are no reachable areas. if len(sorted_areas) == 0 or sorted_areas == [client.area.name]: @@ -4277,7 +4840,7 @@ def ooc_cmd_minimap(client: ClientManager.Client, arg: str): for area_name in sorted_areas: if area_name == client.area.name: continue - area = client.server.area_manager.get_area_by_name(area_name) + area = client.hub.area_manager.get_area_by_name(area_name) info += f'\r\n{area.id}-{area_name}' client.send_ooc(info) @@ -4377,10 +4940,12 @@ def ooc_cmd_multiclients(client: ClientManager.Client, arg: str): def ooc_cmd_music_list(client: ClientManager.Client, arg: str): """ - Sets the client's current music list. This list is persistent between area changes and works on - a client basis. If given no arguments, it will return the music list to its default value - (in music.yaml). The list of music lists can be accessed with /music_lists. Clients that do not - process 'SM' packets can use this command without crashing, but it will have no visual effect. + Sets your current personal music list. This list is persistent between area changes and works on + a client basis. + If given no arguments, it will return the music list to its default value + (in config/music.yaml). + Clients that do not process 'SM' packets can use this command without crashing, but it will + have no visual effect. Returns an error if the given music list name included relative directories, was not found, caused an OS error when loading, or raised a YAML or asset syntax error when loading. @@ -4400,38 +4965,37 @@ def ooc_cmd_music_list(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg) - client.music_manager.command_list_load(client, arg, notify_others=False) + client.music_manager.command_list_load(client, arg, send_notifications=False) + + if arg: + client.send_ooc(f'You are now seeing the personal music list `{arg}`.') + else: + if client.music_manager.if_default_show_hub_music: + client.send_ooc('You are now seeing the hub music list.') + else: + client.send_ooc('You are now seeing the default server music list.') client.send_music_list_view() -def ooc_cmd_music_lists(client: ClientManager.Client, arg: str): +def ooc_cmd_music_list_info(client: ClientManager.Client, arg: str): """ - Lists all available music lists as established in config/music_lists.yaml - Note that, as this file is updated independently from the other music lists, - some music list does not need to be in this file in order to be usable, and - a music list in this list may no longer exist. + Returns your current music list. SYNTAX - /music_lists + /music_list_info PARAMETERS None EXAMPLES - >>> /music_lists - Return all available music lists. + >>> /music_list_info + May return something like this: + | $H: The current music list is the custom list `trial`. """ - Constants.assert_command(client, arg, parameters='=0') + Constants.assert_command(client, arg, parameters='=0') - try: - with Constants.fopen('config/music_lists.yaml', 'r', encoding='utf-8') as f: - output = 'Available music lists:\n' - for line in f: - output += '*{}'.format(line) - client.send_ooc(output) - except ServerError.FileNotFoundError: - raise ClientError('Server file music_lists.yaml not found.') + client.music_manager.command_list_info(client) def ooc_cmd_mute(client: ClientManager.Client, arg: str): @@ -4478,7 +5042,8 @@ def ooc_cmd_notecard(client: ClientManager.Client, arg: str): : Content of your notecard EXAMPLE - /notecard Hello world :: Sets the content of your notecard to `Hello world`. + >>> /notecard Hello world + Sets the content of your notecard to `Hello world`. """ Constants.assert_command(client, arg, parameters='>0') @@ -4505,8 +5070,10 @@ def ooc_cmd_notecard_clear(client: ClientManager.Client, arg: str): {client_id}: Client identifier (number in brackets in /getarea) EXAMPLES - /notecard_clear :: Clears your own notecard - /notecard_clear 2 :: Clears the notecard of player with client ID 2. + >>> /notecard_clear + Clears your own notecard + >>> /notecard_clear 2 + Clears the notecard of player with client ID 2. """ Constants.assert_command(client, arg, parameters='<2') @@ -4549,7 +5116,8 @@ def ooc_cmd_notecard_clear_area(client: ClientManager.Client, arg: str): None EXAMPLES - /notecard_clear_area :: Clears the notecards of all players in your current area. + >>> /notecard_clear_area + Clears the notecards of all players in your current area. """ Constants.assert_command(client, arg, is_staff=True, parameters='=0') @@ -4585,8 +5153,10 @@ def ooc_cmd_notecard_info(client: ClientManager.Client, arg: str): {client_id}: Client identifier (number in brackets in /getarea) EXAMPLES - /notecard_info :: Gets the content of your own notecard - /notecard_info 2 :: Gets the content of the notecard of player with client ID 2. + >>> /notecard_info + Gets the content of your own notecard. + >>> /notecard_info 2 + Gets the content of the notecard of player with client ID 2. """ Constants.assert_command(client, arg, parameters='<2') @@ -4622,7 +5192,8 @@ def ooc_cmd_notecard_check(client: ClientManager.Client, arg: str): None EXAMPLES - /notecard_check :: Returns the contents of all notecards set by players in the current area. + >>> /notecard_check + Returns the contents of all notecards set by players in the current area. """ Constants.assert_command(client, arg, is_staff=True, parameters='=0') @@ -4641,8 +5212,8 @@ def ooc_cmd_notecard_check(client: ClientManager.Client, arg: str): def ooc_cmd_notecard_list(client: ClientManager.Client, arg: str): """ (STAFF ONLY) - Returns the contents of all notecards set by players in the server. - Returns an error if no player in the server have any notecards set. + Returns the contents of all notecards set by players in the hub. + Returns an error if no player in the hub have any notecards set. SYNTAX /notecard_list @@ -4651,14 +5222,15 @@ def ooc_cmd_notecard_list(client: ClientManager.Client, arg: str): None EXAMPLES - /notecard_list :: Returns the contents of all notecards set by players in the server. + >>> /notecard_list + Returns the contents of all notecards set by players in the hub. """ Constants.assert_command(client, arg, is_staff=True, parameters='=0') - with_notecards = [target for target in client.server.get_clients() if target.notecard] + with_notecards = [target for target in client.hub.get_players() if target.notecard] if not with_notecards: - raise ClientError('No players in the server have any notecards set.') + raise ClientError('No players in the hub have any notecards set.') output = '' for target in sorted(with_notecards): @@ -4680,7 +5252,8 @@ def ooc_cmd_notecard_reveal(client: ClientManager.Client, arg: str): None EXAMPLES - /notecard_reveal :: Reveals the contents of all notecards set by players in the area. + >>> /notecard_reveal + Reveals the contents of all notecards set by players in the area. """ Constants.assert_command(client, arg, is_staff=True, parameters='=0') @@ -4696,8 +5269,7 @@ def ooc_cmd_notecard_reveal(client: ClientManager.Client, arg: str): client.send_ooc('You revealed all notecards in the area.') client.send_ooc_others(f'(X) {client.displayname} [{client.id}] revealed all notecards in ' f'area {client.area.name} ({client.area.id}).', is_zstaff_flex=True) - client.area.broadcast_ooc(f'The notecards in the area were revealed: ' - f'{output}') + client.area.broadcast_ooc(f'The notecards in the area were revealed: {output}') def ooc_cmd_notecard_reveal_count(client: ClientManager.Client, arg: str): @@ -4715,7 +5287,8 @@ def ooc_cmd_notecard_reveal_count(client: ClientManager.Client, arg: str): None EXAMPLES - /notecard_reveal_count :: Reveals the frequency of each notecard. + >>> /notecard_reveal_count + Reveals the frequency of each notecard. """ Constants.assert_command(client, arg, is_staff=True, parameters='=0') @@ -4740,8 +5313,7 @@ def ooc_cmd_notecard_reveal_count(client: ClientManager.Client, arg: str): client.send_ooc_others(f'(X) {client.displayname} [{client.id}] revealed the tally of all ' f'notecards in area {client.area.name} ({client.area.id}).', is_zstaff_flex=True) - client.area.broadcast_ooc(f'The tally of all notecards in the area was revealed: ' - f'{output}') + client.area.broadcast_ooc(f'The tally of all notecards in the area was revealed: {output}') def ooc_cmd_noteworthy(client: ClientManager.Client, arg: str): @@ -4785,35 +5357,110 @@ def ooc_cmd_noteworthy(client: ClientManager.Client, arg: str): client) -def ooc_cmd_nsd(client: ClientManager.Client, arg: str): +def ooc_cmd_noteworthy_info(client: ClientManager.Client, arg: str): """ (STAFF ONLY) - Starts an NSD with the players of your trial in your area with time limit if given, defaulting - to no time limit if not given. The NSD creator is automatically added as a NSD leader. - Players in the area not part of the trial, already part of a minigame or that do not have a - character are not added to the NSD. Players added to the NSD are ordered to switch to the - 'nsd' gamemode. - Returns an error if you are not part of a trial or leader of one, if the trial reached its - NSD limit, if you are already part of a minigame or do not have a character, or if the - time is negative or above the server time limit. + Gets the noteworthy status and noteworthy text of the current area. SYNTAX - /nsd {length} + /noteworthy_info PARAMETERS None - OPTIONAL PARAMETERS - {length}: time in seconds, or in mm:ss, or in h:mm:ss; limited to TIMER_LIMIT in function - Constants.parse_time_length. If given, it must be a positive integer. - EXAMPLES - >>> /nsd 3:00 - Starts an NSD with 3 minutes of time. - >>> /nsd 120 - Starts an NSD with 120 seconds of time. + >>> /noteworthy_info + | $H: The current area is currently noteworthy. The current noteworthy text is `[Test]`. """ - try: + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + + status = {True: 'is', False: 'is not'} + client.send_ooc(f'The current area {status[client.area.noteworthy]} currently noteworthy. ' + f'The current noteworthy text is `{client.area.noteworthy_text}`.') + + +def ooc_cmd_noteworthy_set(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Sets (and replaces!) the noteworthy text of the current area to the given one. + If not given any text, it will set the text to be the area's default noteworthy text. + The noteworthy text does not reset or change if the noteworthy status of an area changes. + + SYNTAX + /noteworthy_set {text} + + PARAMETERS + None + + OPTIONAL PARAMETERS + {text}: New noteworthy text. + + EXAMPLES + Assuming you are in area 0 + >>> /noteworthy_set [You notice some broken glass on the floor] + Sets the area noteworthy text in area 0 to be "[You notice some broken glass on the floor]". + >>> /noteworthy_set + Sets the area noteworthy text in area 0 to be the default text. + """ + + Constants.assert_command(client, arg, is_staff=True) + + if not arg: + client.area.noteworthy_text = client.area.default_noteworthy_text + client.send_ooc('Reset the area noteworthy text to its original value.') + client.send_ooc_others('(X) {} [{}] reset the area noteworthy text of your area to its ' + 'original value.' + .format(client.displayname, client.id), + is_zstaff_flex=True, in_area=True) + client.send_ooc_others('(X) {} [{}] reset the area noteworthy text of area {} to its ' + 'original value.' + .format(client.displayname, client.id, client.area.name), + is_zstaff_flex=True, in_area=False) + logger.log_server('[{}][{}]Reset the area noteworthy text in {}.' + .format(client.area.id, client.get_char_name(), client.area.name), client) + + else: + client.area.noteworthy_text = arg + client.send_ooc('Updated the area noteworthy text to `{}`.'.format(arg)) + client.send_ooc_others('(X) {} [{}] set the area noteworthy text of your area to `{}`.' + .format(client.displayname, client.id, client.area.noteworthy_text), + is_zstaff_flex=True, in_area=True) + client.send_ooc_others('(X) {} [{}] set the area noteworthy text of area {} to `{}`.' + .format(client.displayname, client.id, client.area.name, + client.area.noteworthy_text), + is_zstaff_flex=True, in_area=False) + logger.log_server('[{}][{}]Set the area noteworthy text to {}.' + .format(client.area.id, client.get_char_name(), arg), client) + + +def ooc_cmd_nsd(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Starts an NSD with the players of your trial in your area with time limit if given, defaulting + to no time limit if not given. The NSD creator is automatically added as a NSD leader. + Players in the area not part of the trial, already part of a minigame or that do not have a + character are not added to the NSD. Players added to the NSD are ordered to switch to the + 'nsd' gamemode. + Returns an error if you are not part of a trial or leader of one, if the trial reached its + NSD limit, if you are already part of a minigame or do not have a participant character, or if + the time is negative or above the server time limit. + + SYNTAX + /nsd {length} + + PARAMETERS + None + + OPTIONAL PARAMETERS + {length}: time in seconds, or in mm:ss, or in h:mm:ss; limited to TIMER_LIMIT in function + Constants.parse_time_length. If given, it must be a positive integer. + + EXAMPLES + >>> /nsd 3:00 + Starts an NSD with 3 minutes of time. + >>> /nsd 120 + Starts an NSD with 120 seconds of time. + """ + + try: Constants.assert_command(client, arg, is_staff=True, parameters='<2') except ArgumentError: seconds = 300 @@ -4824,7 +5471,7 @@ def ooc_cmd_nsd(client: ClientManager.Client, arg: str): seconds = Constants.parse_time_length(arg) # Also internally validates try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial. You must start a trial with /trial before ' 'starting a nonstop debate.') @@ -4832,9 +5479,13 @@ def ooc_cmd_nsd(client: ClientManager.Client, arg: str): raise ClientError('You are not a leader of your trial.') try: - nsd = trial.new_nsd(creator=client, add_players=False, timer_start_value=seconds, - require_character=True, - autoadd_on_trial_player_add=trial.get_autoadd_on_client_enter()) + nsd = trial.new_nsd( + creator=client, + autoadd_on_creation_existing_users=False, + timer_start_value=seconds, + require_participant_character=True, + autoadd_on_trial_player_add=trial.get_autoadd_on_client_enter() + ) except TrialError.ManagerTooManyGamesError: raise ClientError('The trial already has an active nonstop debate. End the previous one ' 'with /nsd_end.') @@ -4843,7 +5494,7 @@ def ooc_cmd_nsd(client: ClientManager.Client, arg: str): except NonStopDebateError.UserHitGameConcurrentLimitError: raise ClientError('You are already part of another minigame in your trial.') except NonStopDebateError.UserHasNoCharacterError: - raise ClientError('You must have a character to create a nonstop debate.') + raise ClientError('You must have a participant character to create a nonstop debate.') if seconds > 0: client.send_ooc(f'You have created nonstop debate `{nsd.get_id()}` in area ' @@ -4867,7 +5518,7 @@ def ooc_cmd_nsd(client: ClientManager.Client, arg: str): f'they are already part of another minigame.') except NonStopDebateError.UserHasNoCharacterError: client.send_ooc(f'Unable to add player {user.displayname} [{user.id}]: ' - f'they must have a character to join this minigame.') + f'they must have a participant character to join this minigame.') players = sorted(nsd.get_players(), key=lambda c: c.displayname) player_list = '\n'.join([ @@ -4906,7 +5557,7 @@ def ooc_cmd_nsd_accept(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') try: @@ -4951,7 +5602,7 @@ def ooc_cmd_nsd_add(client: ClientManager.Client, arg: str): Adds another user to your NSD. Returns an error if you are not a part of a trial or an NSD or is not a leader, if the NSD reached its player limit, or if the target cannot be found, is not part of the trial, does - not have a character or is part of some NSD. + not have a participant character or is part of some NSD. SYNTAX /nsd_add @@ -4969,7 +5620,7 @@ def ooc_cmd_nsd_add(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='>0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') @@ -4990,7 +5641,8 @@ def ooc_cmd_nsd_add(client: ClientManager.Client, arg: str): except NonStopDebateError.UserNotInAreaError: raise ClientError('This player is not part of an area part of this nonstop debate.') except NonStopDebateError.UserHasNoCharacterError: - raise ClientError('This player must have a character to join this nonstop debate.') + raise ClientError('This player must have a participant character to join this nonstop ' + 'debate.') except NonStopDebateError.UserHitGameConcurrentLimitError: raise ClientError('This player is already part of another nonstop debate.') except NonStopDebateError.UserAlreadyPlayerError: @@ -4998,8 +5650,8 @@ def ooc_cmd_nsd_add(client: ClientManager.Client, arg: str): client.send_ooc(f'You added {target.displayname} [{target.id}] to your nonstop debate.') client.send_ooc_others(f'(X) {client.displayname} added {target.displayname} [{target.id}] ' - 'to your nonstop ' - f'debate.', pred=lambda c: c in trial.get_leaders()) + f'to your nonstop debate.', + pred=lambda c: c in trial.get_leaders()) target.send_ooc(f'You were added to the nonstop debate `{nsd.get_id()}`.') @@ -5026,7 +5678,7 @@ def ooc_cmd_nsd_autoadd(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') try: @@ -5067,7 +5719,7 @@ def ooc_cmd_nsd_end(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') try: @@ -5111,7 +5763,7 @@ def ooc_cmd_nsd_info(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') try: @@ -5136,8 +5788,8 @@ def ooc_cmd_nsd_join(client: ClientManager.Client, arg: str): """ (STAFF ONLY) Enrolls you into a nonstop debate by nonstop debate ID. Returns an error if you are not part of a trial, if the NSD ID is invalid, if you are not part - of an area part of the NSD, if you do not have a character when trying to join the NSD, or if - you are already part of this or another NSD. + of an area part of the NSD, if you do not have a participant character when trying to join the + NSD, or if you are already part of this or another NSD. SYNTAX /nsd_join @@ -5153,7 +5805,7 @@ def ooc_cmd_nsd_join(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='>0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') @@ -5167,7 +5819,7 @@ def ooc_cmd_nsd_join(client: ClientManager.Client, arg: str): except NonStopDebateError.UserNotInAreaError: raise ClientError('You are not part of an area part of this nonstop debate.') except NonStopDebateError.UserHasNoCharacterError: - raise ClientError('You must have a character to join this nonstop debate.') + raise ClientError('You must have a participant character to join this nonstop debate.') except NonStopDebateError.UserHitGameConcurrentLimitError: raise ClientError('You are already part of another nonstop debate.') except NonStopDebateError.UserAlreadyPlayerError: @@ -5200,7 +5852,7 @@ def ooc_cmd_nsd_kick(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='>0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') try: @@ -5217,7 +5869,7 @@ def ooc_cmd_nsd_kick(client: ClientManager.Client, arg: str): try: nsd.remove_player(target) - except TrialError.UserNotPlayerError: + except NonStopDebateError.UserNotPlayerError: raise ClientError('This player is not part of your nonstop debate.') client.send_ooc(f'You have kicked {target.displayname} [{target.id}] off your nonstop debate.') @@ -5247,7 +5899,7 @@ def ooc_cmd_nsd_lead(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') try: @@ -5280,13 +5932,13 @@ def ooc_cmd_nsd_leave(client: ClientManager.Client, arg: str): EXAMPLES >>> /nsd_leave - Makes you leave your current nonstop debate. + Makes you leave your current NSD. """ Constants.assert_command(client, arg, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') @@ -5330,13 +5982,13 @@ def ooc_cmd_nsd_loop(client: ClientManager.Client, arg: str): EXAMPLE >>> /nsd_loop - Loops your NSD + Loops your NSD. """ Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') try: @@ -5377,13 +6029,13 @@ def ooc_cmd_nsd_pause(client: ClientManager.Client, arg: str): EXAMPLE >>> /nsd_pause - Pauses your NSD + Pauses your NSD. """ Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') try: @@ -5429,7 +6081,7 @@ def ooc_cmd_nsd_resume(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') try: @@ -5479,7 +6131,7 @@ def ooc_cmd_nsd_reject(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') try: @@ -5534,7 +6186,7 @@ def ooc_cmd_nsd_unlead(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') try: @@ -5545,11 +6197,11 @@ def ooc_cmd_nsd_unlead(client: ClientManager.Client, arg: str): try: nsd.remove_leader(client) except TrialError.UserNotLeaderError: - raise ClientError('You are already not a leader of this NSD.') + raise ClientError('You are already not a leader of this nonstop debate.') - client.send_ooc('You are no longer a leader of your NSD.') + client.send_ooc('You are no longer a leader of your nonstop debate.') client.send_ooc_others(f'(X) {client.displayname} [{client.id}] is no longer a leader of your ' - f'NSD.', pred=lambda c: c in nsd.get_leaders()) + f'nonstop debate.', pred=lambda c: c in nsd.get_leaders()) def ooc_cmd_online(client: ClientManager.Client, arg: str): @@ -5649,8 +6301,8 @@ def ooc_cmd_paranoia(client: ClientManager.Client, arg: str): which the server, with probability "player paranoia + zone paranoia", starts a timer of length a random number less than 150 seconds, after which it sends the user a phantom peek message if they are not blind and not staff, in an area that is not a lobby or private area, and they - have a valid character selected. A new phantom peek cycle is restarted regardless of success - after the old one expires. + have a participant character selected. A new phantom peek cycle is restarted regardless of + success after the old one expires. Returns an error if the given identifier does not correspond to a user, or if the new player paranoia level is not a number from -100 to 100. @@ -5677,7 +6329,7 @@ def ooc_cmd_paranoia(client: ClientManager.Client, arg: str): except ValueError: raise ClientError('New player paranoia value must be a number.') if not (-100 <= paranoia <= 100): - raise ClientError('New player paranoia value must be a number from 0 to 100.') + raise ClientError('New player paranoia value must be a number from -100 to 100.') target.paranoia = paranoia client.send_ooc(f'You set the player paranoia level of {target.displayname} [{target.id}] to ' @@ -6286,8 +6938,8 @@ def ooc_cmd_passage_clear(client: ClientManager.Client, arg: str): areas = Constants.parse_two_area_names(client, arg.split(', ')) for i in range(areas[0].id, areas[1].id+1): - area = client.server.area_manager.get_area_by_id(i) - area.reachable_areas = client.server.area_manager.area_names + area = client.hub.area_manager.get_area_by_id(i) + area.reachable_areas = client.hub.area_manager.area_names if areas[0] == areas[1]: client.send_ooc('Area passage locks have been removed in {}.'.format(areas[0].name)) @@ -6324,7 +6976,7 @@ def ooc_cmd_passage_restore(client: ClientManager.Client, arg: str): areas = Constants.parse_two_area_names(client, arg.split(', ')) for i in range(areas[0].id, areas[1].id+1): - area = client.server.area_manager.get_area_by_id(i) + area = client.hub.area_manager.get_area_by_id(i) area.reachable_areas = set(list(area.default_reachable_areas)[:]) area.change_reachability_allowed = area.default_change_reachability_allowed @@ -6385,7 +7037,7 @@ def ooc_cmd_peek(client: ClientManager.Client, arg: str): if area_lock_ok and reachable_ok: if target_area.lights: - _, area_description, player_description = target_area.get_look_output_for(client) + _, _, area_description, _, player_description = target_area.get_look_output_for(client) client.send_ooc( f'You peek into area {target_area.name} and note the following:\r\n' f'*About the people in there: you see {player_description}\r\n' @@ -6503,7 +7155,7 @@ def ooc_cmd_play(client: ClientManager.Client, arg: str): client.music_manager.get_music_data(arg) except MusicError.MusicNotFoundError: client.send_ooc(f'Warning: `{arg}` is not a recognized track name, so the server will not ' - 'loop it.') + f'loop it.') def ooc_cmd_pm(client: ClientManager.Client, arg: str): @@ -6549,6 +7201,43 @@ def ooc_cmd_pm(client: ClientManager.Client, arg: str): .format(client.name, client.area.name, client.displayname, msg)) +def ooc_cmd_pm_gms(client: ClientManager.Client, arg: str): + """ + Sends a personal message to all users with rank of GM or above other than yourself in your hub. + Returns an error if no such users could be found, or if you or all such users muted PMs. + + SYNTAX + /pm + + PARAMETERS + : Message to be sent. + + EXAMPLES + >>> /pm_gms What will I get for Christmas? + Sends that message to all GMs in your hub. + """ + + Constants.assert_command(client, arg, parameters='>0') + if client.pm_mute: + raise ClientError('You have muted all PM conversations.') + + targets = {target for target in client.hub.get_players() if target.is_staff()} + targets = targets-{client} + if not targets: + raise ClientError('No GMs are available in your hub.') + + # Only send messages to targets who have not muted PMs + targets = {target for target in targets if not target.pm_mute} + if not targets: + raise ClientError('No GMs available in your hub have PMs enabled.') + + msg = arg + client.send_ooc(f'PM sent to all GMs in hub {client.hub.get_numerical_id()}. Message: {msg}.') + for target in targets: + target.send_ooc(f'(X) PM from {client.displayname} [{client.id}] in {client.area.name} ' + f'({client.area.id}) to all GMs in your hub: {msg}') + + def ooc_cmd_poison(client: ClientManager.Client, arg: str): """ (STAFF ONLY) Poisons the target with some of three effects (blindness, deafened or gagged) that kick in @@ -6809,8 +7498,8 @@ def ooc_cmd_randommusic(client: ClientManager.Client, arg: str): def ooc_cmd_refresh(client: ClientManager.Client, arg: str): - """ (MOD ONLY) - Reloads the following files for the server: characters, default music list, and background list. + """ (STAFF ONLY) + Reloads the following files for your hub: characters, default music list, and background list. SYNTAX /refresh @@ -6820,13 +7509,13 @@ def ooc_cmd_refresh(client: ClientManager.Client, arg: str): EXAMPLE >>> /refresh - Reloads server assets. + Reloads hub assets. """ - Constants.assert_command(client, arg, is_mod=True, parameters='=0') + Constants.assert_command(client, arg, is_staff=True, parameters='=0') - client.server.reload() - client.send_ooc('You have reloaded the server.') + client.hub.refresh() + client.send_ooc('You have refreshed your hub.') def ooc_cmd_reload(client: ClientManager.Client, arg: str): @@ -7109,7 +7798,7 @@ def ooc_cmd_rplay(client: ClientManager.Client, arg: str): except ArgumentError: raise ArgumentError('You must specify a song.') - areas = {client.server.area_manager.get_area_by_name(reachable_area_name) + areas = {client.hub.area_manager.get_area_by_name(reachable_area_name) for reachable_area_name in client.area.visible_areas} for area in areas: @@ -7134,50 +7823,6 @@ def ooc_cmd_rplay(client: ClientManager.Client, arg: str): f'not loop it.') -def ooc_cmd_rpmode(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) - Toggles RP mode on/off in the server. If turned on, all non-logged in users will be subject to - RP rules. Some effects include: unable to use /getarea and /getareas in areas that disable it. - - This command is deprecated and pending removal in 4.4. - - SYNTAX - /rpmode - - PARAMETERS - : 'on' or 'off' - - EXAMPLES - >>> /rpmode on - Turns on RP mode. - >>> /rpmode off - Turns off RP mode. - """ - - client.send_ooc('This command is deprecated and pending removal in 4.4.') - - try: - Constants.assert_command(client, arg, is_staff=True, parameters='=1') - except ArgumentError: - raise ArgumentError('You must specify either on or off.') - if not client.server.config['rp_mode_enabled']: - raise ClientError("RP mode is disabled in this server.") - - if arg == 'on': - client.server.rp_mode = True - for c in client.server.get_clients(): - c.send_ooc('RP mode enabled.') - if not c.is_staff(): - c.in_rp = True - elif arg == 'off': - client.server.rp_mode = False - for c in client.server.get_clients(): - c.send_ooc('RP mode disabled.') - c.in_rp = False - else: - client.send_ooc('Expected on or off.') - - def ooc_cmd_scream(client: ClientManager.Client, arg: str): """ Sends a message in the OOC chat visible to all staff members and non-deaf users that are in an @@ -7300,7 +7945,7 @@ def ooc_cmd_scream_range(client: ClientManager.Client, arg: str): info += '\r\n*No areas.' # Otherwise, build the list of all areas. else: - areas = [client.server.area_manager.get_area_by_name(area_name) + areas = [client.hub.area_manager.get_area_by_name(area_name) for area_name in client.area.scream_range] for area in sorted(areas, key=lambda area: area.id): info += '\r\n*{}-{}'.format(area.id, area.name) @@ -7373,7 +8018,7 @@ def ooc_cmd_scream_set_range(client: ClientManager.Client, arg: str): Passing in no arguments sets the scream range to nothing (i.e. a soundproof room). Note that scream ranges are unidirectional, so if you want two areas to hear one another, you must use this command twice. - The special keyword means all areas in the server should be able to listen to screams + The special keyword means all areas in the hub should be able to listen to screams from the current area. The special keyword means all areas reachable from the current area. Returns an error if an invalid area name or area ID is given, if the current area is part of @@ -7412,7 +8057,7 @@ def ooc_cmd_scream_set_range(client: ClientManager.Client, arg: str): raise ArgumentError('You may not include multiple areas when including a special ' 'keyword.') area_names = '' - client.area.scream_range = {area.name for area in client.server.area_manager.get_areas() + client.area.scream_range = {area.name for area in client.hub.area_manager.get_areas() if area != client.area} elif '' in raw_areas: if len(raw_areas) != 1: @@ -7529,10 +8174,11 @@ def ooc_cmd_showname_freeze(client: ClientManager.Client, arg: str): client.send_ooc('You have {} all shownames.'.format(status[client.server.showname_freeze])) client.send_ooc_others('A mod has {} all shownames.' - .format(status[client.server.showname_freeze]), is_officer=False) + .format(status[client.server.showname_freeze]), + is_officer=False, in_hub=None) client.send_ooc_others('{} [{}] has {} all shownames.' .format(client.name, client.id, status[client.server.showname_freeze]), - is_officer=True) + is_officer=True, in_hub=None) logger.log_server('{} has {} all shownames.' .format(client.name, status[client.server.showname_freeze]), client) @@ -7598,9 +8244,9 @@ def ooc_cmd_showname_nuke(client: ClientManager.Client, arg: str): c.change_showname('') client.send_ooc('You have nuked all shownames.') - client.send_ooc_others('A mod has nuked all shownames.', is_officer=False) + client.send_ooc_others('A mod has nuked all shownames.', is_officer=False, in_hub=None) client.send_ooc_others('{} [{}] has nuked all shownames.' - .format(client.name, client.id), is_officer=True) + .format(client.name, client.id), is_officer=True, in_hub=None) logger.log_server('{} has nuked all shownames.'.format(client.name), client) @@ -7647,20 +8293,19 @@ def ooc_cmd_showname_set(client: ClientManager.Client, arg: str): for c in Constants.parse_id_or_ipid(client, user_id): old_showname = c.showname if old_showname == showname == '': - client.send_ooc('Unable to set the showname of client {}: target already does not ' - 'have a showname.').format(c.id) + client.send_ooc(f'Unable to clear the showname of client {c.id}: ' + f'target already does not have a showname.') continue if old_showname == showname: - client.send_ooc('Unable to set the showname of client {}: target already has that ' - 'a showname.').format(c.id) + client.send_ooc(f'Unable to set the showname of client {c.id}: ' + f'target already has that showname.') continue try: c.change_showname(showname) except (ClientError, ValueError) as exc: - client.send_ooc('Unable to set the showname of client {}: {}'.format(c.id, exc)) + client.send_ooc(f'Unable to set the showname of client {c.id}: {exc}') continue - # This also handles the case where old_showname == showname (possibly == '') if showname: if old_showname: @@ -7683,11 +8328,11 @@ def ooc_cmd_showname_set(client: ClientManager.Client, arg: str): l_message = ('Set showname of {} to {}.' .format(c.ipid, showname)) else: - s_message = 'You have removed the showname of client {}.'.format(c.id) - w_message = ('(X) {} [{}] removed the showname `{}` of client {} in your zone ({}).' + s_message = 'You have cleared the showname of client {}.'.format(c.id) + w_message = ('(X) {} [{}] cleared the showname `{}` of client {} in your zone ({}).' .format(client.displayname, client.id, old_showname, c.id, c.area.id)) - o_message = 'Your showname `{}` was removed by a staff member.'.format(old_showname) - l_message = 'Removed showname {} of {}.'.format(old_showname, c.ipid) + o_message = 'Your showname `{}` was cleared by a staff member.'.format(old_showname) + l_message = 'Cleared showname {} of {}.'.format(old_showname, c.ipid) client.send_ooc(s_message) client.send_ooc_others(w_message, not_to={c}, is_zstaff=c.area) @@ -7758,6 +8403,47 @@ def ooc_cmd_sneak(client: ClientManager.Client, arg: str): c.change_visibility(False) +def ooc_cmd_sneakself(client: ClientManager.Client, arg: str): + """ (STAFF ONLY+VARYING REQUIREMENTS) + Makes all opened multiclients be sneaked without having to manually sneak them. + Opened multiclients that are already sneaked are unaffected. + If a multiclient is in a private area, or in a lobby area and you are not an officer, or is + already sneaked, the sneak will fail for that multiclient. + Returns an error if no opened multiclients can successfully be sneaked. + + SYNTAX + /sneakself + + EXAMPLES + If user with client ID 0 is GM has multiclients with ID 1 and 3, neither sneaked, and runs... + >>> /sneakself + Sneaks clients 0, 1 and 3. + """ + + Constants.assert_command(client, arg, is_staff=True) + + targets = [c for c in client.get_multiclients() if c.is_visible] + targets = [c for c in targets if not c.area.private_area] + if not client.is_officer(): + targets = [c for c in targets if c.area.lobby_area] + if not targets: + raise ClientError('No opened clients can be sneaked.') + + # Sneak matching targets + for c in targets: + c.change_visibility(False) + + client.send_ooc("You sneaked all of your valid multiclients.") + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] sneaked all their valid ' + f'multiclients [{client.id}] ({client.area.id}).', + not_to=set(targets), is_zstaff=True) + + non_targets = [c for c in client.get_multiclients() if c not in targets] + if non_targets: + s_non_targets = Constants.cjoin([f'{c.displayname} [{c.id}]' for c in non_targets]) + client.send_ooc(f'The following clients could not be sneaked: {s_non_targets}') + + def ooc_cmd_spectate(client: ClientManager.Client, arg: str): """ Switches your current character to the SPECTATOR character. @@ -7785,6 +8471,86 @@ def ooc_cmd_spectate(client: ClientManager.Client, arg: str): client.send_ooc('You are now spectating.') +def ooc_cmd_summon(client: ClientManager.Client, arg: str): + """ (STAFF ONLY+VARYING REQUIREMENTS) + Summons a user by client ID or IPID to a given area by area ID or name, or your area if + not given an area. GMs cannot perform this command on users in lobby areas. + If given IPID, it will summon all clients you opened. Otherwise, it will just summon the given + user. Search by IPID can only be performed by CMs and mods. + Returns an error if the given identifier does not correspond to a user, or if there was some + sort of error in the process of summoning the user to the area (e.g. full area). + + SYNTAX + /summon {target_area} + /summon {target_area} + + PARAMETERS + : Client identifier (number in brackets in /getarea) + : IPID for the client (number in parentheses in /getarea) + + OPTIONAL PARAMETERS + {target_area}: Intended area to summon the user to, by area ID or name + + EXAMPLES + Assuming yo are in area 0... + >>> /summon 1 + Summons the user with client ID 1 to area 0. + >>> /summon 1234567890 3 + Summons all the clients opened by the user with IPID 1234567890 to area 3. + >>> /summon 0987654321 Lobby + Summons all the clients opened by the user with IPID 0987654321 to Lobby. + >>> /summon 3 Class Trial Room,\ 2 + Summons the user with client ID 1 to Class Trial Room, 2 (note the ,\). + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='>0', split_spaces=True) + + arg = arg.split(' ') + + if client.area.lobby_area and not client.is_officer(): + raise ClientError('You must be authorized to summon clients in lobby areas.') + + if len(arg) == 1: + area = client.area + else: + area = Constants.parse_area_names(client, [" ".join(arg[1:])])[0] + + for c in Constants.parse_id_or_ipid(client, arg[0]): + # Failsafe in case summoned player has their character changed during the summon + old_displayname = c.displayname + old_area = c.area + + try: + c.change_area(area, override_passages=True, override_effects=True, ignore_bleeding=True, + ignore_autopass=True) + except ClientError as error: + error_mes = ", ".join([str(s) for s in error.args]) + client.send_ooc('Unable to summon {} [{}] to area {}: {}' + .format(old_displayname, c.id, area.id, error_mes)) + else: + client.send_ooc('You summoned {} [{}] from area {} to area {}.' + .format(old_displayname, c.id, old_area.id, area.id)) + c.send_ooc('You were summoned from the area to area {}.'.format(area.id)) + client.send_ooc_others('(X) {} [{}] summoned {} [{}] from area {} to area {}.' + .format(client.displayname, client.id, old_displayname, c.id, + old_area.id, area.id), + not_to={c}, is_staff=True) + + if old_area.is_locked or old_area.is_modlocked: + try: # Try and remove the IPID from the area's invite list + old_area.invite_list.pop(c.ipid) + except KeyError: + # only happens if target joined the locked area through mod powers + pass + + if client.party: + party = client.party + party.remove_member(client) + client.send_ooc('You were also kicked off your party.') + for member in party.get_members(): + member.send_ooc('{} was summoned off your party.'.format(old_displayname)) + + def ooc_cmd_st(client: ClientManager.Client, arg: str): """ (STAFF ONLY) Send a message to the private server-wide staff chat. Only staff members can send and receive @@ -7804,7 +8570,7 @@ def ooc_cmd_st(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True) pre = '{} [Staff] {}'.format(client.server.config['hostname'], client.name) - for c in client.server.get_clients(): + for c in client.hub.get_players(): c.send_ooc(arg, username=pre, pred=lambda c: c.is_staff()) logger.log_server('[{}][STAFFCHAT][{}][{}]{}.' .format(client.area.id, client.get_char_name(), client.name, arg), client) @@ -7952,7 +8718,6 @@ def ooc_cmd_status_set_other(client: ClientManager.Client, arg: str): for c in refreshed_clients: if c == client: continue - c.send_ooc(f'You now note something about {target.displayname}.', is_zstaff_flex=False) target.area.broadcast_ic_attention(ding=False) @@ -7994,7 +8759,7 @@ def ooc_cmd_switch(client: ClientManager.Client, arg: str): raise ArgumentError('You must specify a character name.') # Obtain char_id if character exists and then try and change to given char if available - char_id = client.server.character_manager.get_character_id_by_name(arg) + char_id = client.hub.character_manager.get_character_id_by_name(arg) client.change_character(char_id, force=client.is_mod) client.send_ooc(f'Changed character to {arg}.') @@ -8147,7 +8912,7 @@ def ooc_cmd_timer(client: ClientManager.Client, arg: str): name = arg[1] else: name = client.name.replace(" ", "") + "Timer" # No spaces! - if name in client.server.tasker.active_timers.keys(): + if name in client.server.task_manager.active_timers: raise ClientError('Timer name {} is already taken.'.format(name)) # Check public status @@ -8156,7 +8921,7 @@ def ooc_cmd_timer(client: ClientManager.Client, arg: str): else: is_public = True - client.server.tasker.active_timers[name] = client #Add to active timers list + client.server.task_manager.active_timers[name] = client #Add to active timers list client.send_ooc('You initiated a timer "{}" of length {} seconds.'.format(name, length)) client.send_ooc_others('(X) {} [{}] initiated a timer "{}" of length {} seconds in area {} ' '({}).' @@ -8166,7 +8931,11 @@ def ooc_cmd_timer(client: ClientManager.Client, arg: str): .format(client.displayname, name, length), is_zstaff_flex=False, pred=lambda c: is_public) - client.server.tasker.create_task(client, ['as_timer', time.time(), length, name, is_public]) + client.server.task_manager.new_task(client, 'as_timer', { + 'length': length, + 'timer_name': name, + 'is_public': is_public, + }) def ooc_cmd_timer_end(client: ClientManager.Client, arg: str): @@ -8194,7 +8963,7 @@ def ooc_cmd_timer_end(client: ClientManager.Client, arg: str): timer_name = arg[0] try: - timer_client = client.server.tasker.active_timers[timer_name] + timer_client = client.server.task_manager.active_timers[timer_name] except KeyError: raise ClientError('Timer {} is not an active timer.'.format(timer_name)) @@ -8202,8 +8971,8 @@ def ooc_cmd_timer_end(client: ClientManager.Client, arg: str): if not client.is_staff() and client != timer_client: raise ClientError('You must be authorized to do that.') - timer = client.server.tasker.get_task(timer_client, ['as_timer']) - client.server.tasker.cancel_task(timer) + task = client.server.task_manager.get_task(timer_client, 'as_timer') + client.server.task_manager.force_asyncio_cancelled_error(task) def ooc_cmd_timer_get(client: ClientManager.Client, arg: str): @@ -8241,18 +9010,21 @@ def ooc_cmd_timer_get(client: ClientManager.Client, arg: str): if len(arg) == 1: # Check specific timer timer_name = arg[0] - if timer_name not in client.server.tasker.active_timers.keys(): + if timer_name not in client.server.task_manager.active_timers: raise ClientError('Timer {} is not an active timer.'.format(timer_name)) timers_to_check = [timer_name] else: # Case len(arg) == 0 # List all timers - timers_to_check = client.server.tasker.active_timers.keys() - if len(timers_to_check) == 0: + timers_to_check = client.server.task_manager.active_timers.keys() + if not timers_to_check: raise ClientError('No active timers.') for timer_name in timers_to_check: - timer_client = client.server.tasker.active_timers[timer_name] - start, length, _, is_public = client.server.tasker.get_task_args(timer_client, ['as_timer']) + timer_client = client.server.task_manager.active_timers[timer_name] + task = client.server.task_manager.get_task(timer_client, 'as_timer') + start = task.creation_time + length = task.parameters['length'] + is_public = task.parameters['is_public'] # Non-public timers can only be consulted by staff and the client who started the timer if not is_public and not (client.is_staff() or client == timer_client): @@ -8445,6 +9217,39 @@ def ooc_cmd_toggle_global(client: ClientManager.Client, arg: str): client.send_ooc('You will {} receive global messages.'.format(status[client.muted_global])) +def ooc_cmd_toggle_music_list_default(client: ClientManager.Client, arg: str): + """ + Toggles the option that controls which music list shown when no personal music list is active: + the current hub music list (default), or the server default music list. + + SYNTAX + /toggle_music_list_default + + PARAMETERS + None + + EXAMPLES + Assuming that the current option makes the current hub music list be shown... + >>> /toggle_music_list_default + The server default music list will now be shown when no personal music list is active. + >>> /toggle_music_list_default + The current hub music list will now be shown when no personal music list is active. + """ + + Constants.assert_command(client, arg, parameters='=0') + + new_value = not client.music_manager.if_default_show_hub_music + client.music_manager.if_default_show_hub_music = new_value + + if new_value: + client.send_ooc('You will now see the hub music list whenever you do not have a ' + 'personal music list active.') + else: + client.send_ooc('You will now see the server music list whenever you do not have a ' + 'personal music list active.') + client.send_music_list_view() + + def ooc_cmd_toggle_pm(client: ClientManager.Client, arg: str): """ Toggles between being able to receive PMs or not. @@ -8539,10 +9344,10 @@ def ooc_cmd_transient(client: ClientManager.Client, arg: str): def ooc_cmd_trial(client: ClientManager.Client, arg: str): """ (STAFF ONLY) Starts a trial with all players in the area. Players that are already part of a trial or that - lack a character are not added to a trial. The trial creator is automatically added as a - trial leader. + lack a participant character are not added to a trial. The trial creator is automatically added + as a trial leader. Players added to a trial are ordered to switch to the 'trial' theme gamemode. - Returns an error if the server has reached its trial limit, or if you are part of another + Returns an error if the hub has reached its trial limit, or if you are part of another trial or have no character. SYNTAX @@ -8560,20 +9365,23 @@ def ooc_cmd_trial(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.new_trial(creator=client, add_players=False, - require_character=True, - autoadd_on_client_enter=False, - autoadd_minigame_on_player_added=False) + trial = client.hub.trial_manager.new_managee( + creator=client, + autoadd_on_creation_existing_users=False, + require_participant_character=True, + autoadd_on_client_enter=False, + autoadd_minigame_on_player_added=False + ) except TrialError.AreaDisallowsBulletsError: raise ClientError('This area disallows bullets.') except TrialError.AreaHitGameConcurrentLimitError: raise ClientError('This area already hosts another trial.') except TrialError.ManagerTooManyGamesError: - raise ClientError('The server has reached its trial limit.') + raise ClientError('The hub has reached its trial limit.') except TrialError.UserHitGameConcurrentLimitError: raise ClientError('You are already part of another trial.') except TrialError.UserHasNoCharacterError: - raise ClientError('You must have a character to create a trial.') + raise ClientError('You must have a participant character to create a trial.') client.send_ooc(f'You have created trial `{trial.get_id()}` in area {client.area.name}.') trial.add_leader(client) @@ -8588,7 +9396,7 @@ def ooc_cmd_trial(client: ClientManager.Client, arg: str): f'they are already part of another trial.') except TrialError.UserHasNoCharacterError: client.send_ooc(f'Unable to add player {user.displayname} [{user.id}]: ' - f'they must have a character to join this trial.') + f'they must have a participant character to join this trial.') players = sorted(trial.get_players(), key=lambda c: c.displayname) player_list = '\n'.join([ @@ -8609,8 +9417,8 @@ def ooc_cmd_trial_add(client: ClientManager.Client, arg: str): """ (STAFF ONLY) Adds another user to your the trial. Returns an error if you are not a part of a trial or is not a leader, if the trial - reached its player limit, or if the target cannot be found, does not have a character or is - part of some trial. + reached its player limit, or if the target cannot be found, does not have a participant + character or is part of some trial. SYNTAX /trial_add @@ -8628,7 +9436,7 @@ def ooc_cmd_trial_add(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='>0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') if not trial.is_leader(client): @@ -8642,7 +9450,7 @@ def ooc_cmd_trial_add(client: ClientManager.Client, arg: str): except TrialError.UserNotInAreaError: raise ClientError('This player is not part of an area part of this trial.') except TrialError.UserHasNoCharacterError: - raise ClientError('This player must have a character to join this trial.') + raise ClientError('This player must have a participant character to join this trial.') except TrialError.UserHitGameConcurrentLimitError: raise ClientError('This player is already part of another trial.') except TrialError.UserAlreadyPlayerError: @@ -8678,7 +9486,7 @@ def ooc_cmd_trial_autoadd(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') if not trial.is_leader(client): @@ -8715,7 +9523,7 @@ def ooc_cmd_trial_end(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') if not trial.is_leader(client): @@ -8760,7 +9568,7 @@ def ooc_cmd_trial_focus(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='>1') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') if not trial.is_leader(client): @@ -8813,7 +9621,7 @@ def ooc_cmd_trial_influence(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='>1') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') if not trial.is_leader(client): @@ -8862,7 +9670,7 @@ def ooc_cmd_trial_info(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') @@ -8874,8 +9682,8 @@ def ooc_cmd_trial_join(client: ClientManager.Client, arg: str): """ (STAFF ONLY) Enrolls you into a trial by trial ID. Returns an error if the trial ID is invalid, if you are not part of an area part of the trial, - if you do not have a character when trying to join the trial, or if you are already part of - this or another trial. + if you do not have a participant character when trying to join the trial, or if you are already + part of this or another trial. SYNTAX /trial_join @@ -8891,7 +9699,7 @@ def ooc_cmd_trial_join(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='>0') try: - trial = client.server.trial_manager.get_game_by_id(arg) + trial = client.hub.trial_manager.get_managee_by_id(arg) except TrialError.ManagerInvalidGameIDError: raise ClientError(f'Unrecognized trial ID `{arg}`.') @@ -8900,7 +9708,7 @@ def ooc_cmd_trial_join(client: ClientManager.Client, arg: str): except TrialError.UserNotInAreaError: raise ClientError('You are not part of an area part of this trial.') except TrialError.UserHasNoCharacterError: - raise ClientError('You must have a character to join this trial.') + raise ClientError('You must have a participant character to join this trial.') except TrialError.UserHitGameConcurrentLimitError: raise ClientError('You are already part of another trial.') except TrialError.UserAlreadyPlayerError: @@ -8933,7 +9741,7 @@ def ooc_cmd_trial_kick(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='>0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') if not trial.is_leader(client): @@ -8975,7 +9783,7 @@ def ooc_cmd_trial_lead(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') @@ -9010,7 +9818,7 @@ def ooc_cmd_trial_leave(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') @@ -9052,7 +9860,7 @@ def ooc_cmd_trial_unlead(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') try: - trial = client.server.trial_manager.get_trial_of_user(client) + trial = client.hub.trial_manager.get_managee_of_user(client) except TrialError.UserNotPlayerError: raise ClientError('You are not part of a trial.') @@ -9100,7 +9908,8 @@ def ooc_cmd_unban(client: ClientManager.Client, arg: str): client.send_ooc('Unbanned `{}`.'.format(idnt)) client.send_ooc_others('{} [{}] unbanned `{}`.' - .format(client.name, client.id, idnt), is_officer=True) + .format(client.name, client.id, idnt), + is_officer=True, in_hub=None) logger.log_server('Unbanned {}.'.format(idnt), client) @@ -9139,7 +9948,8 @@ def ooc_cmd_unbanhdid(client: ClientManager.Client, arg: str): client.send_ooc('Unbanned HDID `{}`.'.format(arg)) client.send_ooc_others('{} [{}] unbanned HDID `{}`.' - .format(client.name, client.id, arg), is_officer=True) + .format(client.name, client.id, arg), + is_officer=True, in_hub=None) logger.log_server('HDID-unbanned {}.'.format(arg), client) @@ -9262,8 +10072,6 @@ def ooc_cmd_unfollow(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') except ClientError.UnauthorizedError: Constants.assert_command(client, arg, parameters='=0') - if client.has_character(): - raise ClientError('You must be authorized to unfollow while having a character.') client.unfollow_user() @@ -9442,7 +10250,7 @@ def ooc_cmd_unilock(client: ClientManager.Client, arg: str): areas = Constants.parse_two_area_names(client, areas, area_duplicate=False, check_valid_range=False) - now_reachable = client.server.area_manager.change_passage_lock(client, areas, bilock=False, + now_reachable = client.hub.area_manager.change_passage_lock(client, areas, bilock=False, change_passage_visibility=False) status = {True: 'unlocked', False: 'locked'} @@ -9489,7 +10297,7 @@ def ooc_cmd_unilockh(client: ClientManager.Client, arg: str): areas = Constants.parse_two_area_names(client, arg.split(', '), area_duplicate=False, check_valid_range=False) - now_reachable = client.server.area_manager.change_passage_lock(client, areas, bilock=False, + now_reachable = client.hub.area_manager.change_passage_lock(client, areas, bilock=False, change_passage_visibility=True) status = {True: 'unlocked and revealed', False: 'locked and hid'} @@ -9533,7 +10341,7 @@ def ooc_cmd_uninvite(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, parameters='=1') - if not client.area.is_locked and not client.area.is_modlocked and not client.area.is_gmlocked: + if not client.area.is_locked and not client.area.is_modlocked: raise ClientError('Area is not locked.') targets = list() # Start with empty list @@ -9588,14 +10396,12 @@ def ooc_cmd_unlock(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, parameters='=0') - if not client.area.is_locked and not client.area.is_modlocked and not client.area.is_gmlocked: + if not client.area.is_locked and not client.area.is_modlocked: raise ClientError('Area is already open.') if client.is_mod and client.area.is_modlocked: client.area.modunlock() - elif client.is_staff() and not client.area.is_modlocked: - client.area.gmunlock() - elif not client.area.is_gmlocked and not client.area.is_modlocked: + elif not client.area.is_modlocked: client.area.unlock() else: raise ClientError('You must be authorized to do that.') @@ -9974,10 +10780,10 @@ def ooc_cmd_zone(client: ClientManager.Client, arg: str): raw_area_names = arg.split(', ') if arg else [] lower_area, upper_area = Constants.parse_two_area_names(client, raw_area_names, check_valid_range=True) - areas = client.server.area_manager.get_areas_in_range(lower_area, upper_area) + areas = client.hub.area_manager.get_areas_in_range(lower_area, upper_area) try: - zone_id = client.server.zone_manager.new_zone(areas, {client}) + zone_id = client.hub.zone_manager.new_zone(areas, {client}) except ZoneError.AreaConflictError: raise ZoneError('Some of the areas of your new zone are already part of some other zone.') except ZoneError.WatcherConflictError: @@ -10050,44 +10856,184 @@ def ooc_cmd_zone_add(client: ClientManager.Client, arg: str): c.send_ooc('Your area has been made part of zone `{}`.'.format(zone_id)) -def ooc_cmd_zone_autopass(client: ClientManager.Client, arg: str): +def ooc_cmd_zone_ambient(client: ClientManager.Client, arg: str): """ (STAFF ONLY) - Changes the zone autopass automatic setting of the zone you are watching from False to True, - or True to False, and warns all players in an area part of the zone (as well as zone watchers) - about the change in OOC. Newly created zones have such setting set to False. - If set to True, the autopass setting of all players in an area part of the zone will be turned - on, and so will the autopass setting of any player who later joins an area part of the zone. - If such player already had autopass on, there is no effect. Players are free to change their - autopass setting manually via /autopass. Players who go on to an area part of the zone will - not have the zone change their autopass setting on departure. - If set to False, the autopass setting of all players in an area part of the zone will be turned - off. If such player already had autopass off, there is no effect. - Returns an error if you are not watching a zone. + Sets up the ambient sound effect of all areas in the zone you are watching. Players in areas + part of the zone, and players that later join an area of the zone, will be ordered to play the + area ambient sound effect. + This command is equivalent to calling /ambient in every area of the zone you are watching. + GMs may still individually change or clear ambient sound effects for areas of the zone after + running the command, and such actions will override the "zone ambient". SYNTAX - /zone_autopass + /zone_ambient PARAMETERS - None + : Name of the ambient sound effect EXAMPLES - Assuming you are watching newly created zome z0... - >>> /zone_autopass - Sets the zone autopass automatic setting of the zone z0 to True. - >>> /zone_autopass - Sets the zone autopass automatic setting of the zone z0 to False. + >>> /zone_ambient wind.wav + Sets the ambient sound effect of all areas of the current zone to `wind.wav`. """ - Constants.assert_command(client, arg, is_staff=True, parameters='=0') + Constants.assert_command(client, arg, is_staff=True, parameters='>0') if not client.zone_watched: raise ZoneError('You are not watching a zone.') zone = client.zone_watched - status = {False: 'off', True: 'on'} - try: - zone_autopass = zone.get_property('Autopass') + targets = zone.get_players() + client.send_ooc(f'You have set the ambient sound effect of all areas of your zone to `{arg}`.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] have set the ambient sound ' + f'effect of all areas of your zone to `{arg}` ({client.area.id}).', + is_zstaff=True) + + for c in targets: + c.send_area_ambient(name=arg) + for a in zone.get_areas(): + a.ambient = arg + + +def ooc_cmd_zone_ambient_end(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Clears the ambient sound effect of all areas of the zone you are watching. Players in an area + part of the zone will be ordered to stop playing the former area ambient sound effect, and + players that later join some area of the zone will not play the former area ambient sound + effect. + This command is equivalent to calling /ambient_end in every area of the zone you are watching, + without displaying error messages if it happened to be the case no ambient sound effect was set + for some (or all) of the areas of the zone. + GMs may still individually change or clear ambient sound effects for areas of the zone after + running the command, and such actions will override the "zone ambient". + Returns an error if you are not watching a zone. + + SYNTAX + /zone_ambient + + PARAMETERS + None + + EXAMPLES + >>> /zone_ambient_end + Clears the ambient sound effect of all areas of the zone you are watching. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + + if not client.zone_watched: + raise ZoneError('You are not watching a zone.') + + zone = client.zone_watched + + targets = zone.get_players() + client.send_ooc('You have removed the area ambient sound effect of all areas of your zone.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] removed the area ambient sound ' + f'effect of all areas of your zone ({client.area.id}).', is_zstaff=True) + + for c in targets: + c.send_area_ambient(name='') + for a in zone.get_areas(): + a.ambient = '' + + +def ooc_cmd_zone_autoglance(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Changes the zone autoglance automatic setting of the zone you are watching from False to True, + or True to False, and warns all players in an area part of the zone (as well as zone watchers) + about the change in OOC. Newly created zones have such setting set to False. + If set to True, the autoglance setting of all players in an area part of the zone will be turned + on, and so will the autoglance setting of any player who later joins an area part of the zone. + If such player already had autoglance on, there is no effect. Players are free to change their + autoglance setting manually via /autoglance. Players who go on to an area part of the zone will + not have the zone change their autoglance setting on departure. + If set to False, the autoglance setting of all players in an area part of the zone will be + turned off. If such player already had autoglance off, there is no effect. + Returns an error if you are not watching a zone. + + SYNTAX + /zone_autoglance + + PARAMETERS + None + + EXAMPLES + Assuming you are watching newly created zome z0... + >>> /zone_autoglance + Sets the zone autoglance automatic setting of the zone z0 to True. + >>> /zone_autoglance + Sets the zone autoglance automatic setting of the zone z0 to False. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + + if not client.zone_watched: + raise ZoneError('You are not watching a zone.') + + zone = client.zone_watched + status = {False: 'off', True: 'on'} + + try: + zone_autoglance = zone.get_property('Autoglance') + except ZoneError.PropertyNotFoundError: + zone_autoglance = False + + zone_autoglance = not zone_autoglance + zone.set_property('Autoglance', zone_autoglance) + + status = {True: 'on', False: 'off'} + client.send_ooc(f'You turned {status[zone_autoglance]} autoglance in your zone.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] has turned ' + f'{status[zone_autoglance]} autoglance in your zone ' + f'({client.area.id}).', is_zstaff=True) + client.send_ooc_others(f'Autoglance was automatically turned {status[zone_autoglance]} in your ' + f'zone.', is_zstaff=False, pred=lambda c: c.area.in_zone == zone) + + for player in zone.get_players(): + player.autoglance = zone_autoglance + + logger.log_server(f'[{client.area.id}][{client.get_char_name()}]Changed autoglance in zone ' + f'{zone.get_id()} to {zone_autoglance}.', client) + + +def ooc_cmd_zone_autopass(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Changes the zone autopass automatic setting of the zone you are watching from False to True, + or True to False, and warns all players in an area part of the zone (as well as zone watchers) + about the change in OOC. Newly created zones have such setting set to False. + If set to True, the autopass setting of all players in an area part of the zone will be turned + on, and so will the autopass setting of any player who later joins an area part of the zone. + If such player already had autopass on, there is no effect. Players are free to change their + autopass setting manually via /autopass. Players who go on to an area part of the zone will + not have the zone change their autopass setting on departure. + If set to False, the autopass setting of all players in an area part of the zone will be turned + off. If such player already had autopass off, there is no effect. + Returns an error if you are not watching a zone. + + SYNTAX + /zone_autopass + + PARAMETERS + None + + EXAMPLES + Assuming you are watching newly created zome z0... + >>> /zone_autopass + Sets the zone autopass automatic setting of the zone z0 to True. + >>> /zone_autopass + Sets the zone autopass automatic setting of the zone z0 to False. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='=0') + + if not client.zone_watched: + raise ZoneError('You are not watching a zone.') + + zone = client.zone_watched + status = {False: 'off', True: 'on'} + + try: + zone_autopass = zone.get_property('Autopass') except ZoneError.PropertyNotFoundError: zone_autopass = False @@ -10111,7 +11057,7 @@ def ooc_cmd_zone_autopass(client: ClientManager.Client, arg: str): def ooc_cmd_zone_end(client: ClientManager.Client, arg: str): """ (VARYING REQUIREMENTS) - Deletes the zone you are watching, so that it is no longer part of the server's zone list, + Deletes the zone you are watching, so that it is no longer part of the hub's zone list, if no argument is given (GM OR ABOVE ONLY), or deletes the zone by its name (CM OR MOD ONLY). Returns an error if you are not watching a zone and do not provide a zone ID. @@ -10144,7 +11090,7 @@ def ooc_cmd_zone_end(client: ClientManager.Client, arg: str): if arg: try: - target_zone = client.server.zone_manager.get_zone(arg) + target_zone = client.hub.zone_manager.get_zone(arg) except KeyError: raise ZoneError('`{}` is not a valid zone ID.'.format(arg)) else: @@ -10155,7 +11101,7 @@ def ooc_cmd_zone_end(client: ClientManager.Client, arg: str): backup_watchers = target_zone.get_watchers() # Keep backup reference to send to others backup_id = target_zone.get_id() - client.server.zone_manager.delete_zone(backup_id) + target_zone.manager.delete_zone(backup_id) if arg: client.send_ooc('You have ended zone `{}`.'.format(backup_id)) @@ -10510,7 +11456,7 @@ def ooc_cmd_zone_lights(client: ClientManager.Client, arg: str): def ooc_cmd_zone_list(client: ClientManager.Client, arg: str): """ (STAFF ONLY) - Lists all active zones in the server. For each zone, it lists details such as: zone ID, + Lists all active zones in the hub. For each zone, it lists details such as: zone ID, the number of players it has, the areas it contains, and who is watching it. Returns an error if there are no active zones. @@ -10529,7 +11475,7 @@ def ooc_cmd_zone_list(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=0') - info = client.server.zone_manager.get_info() + info = client.hub.zone_manager.get_info() client.send_ooc(info) @@ -10589,8 +11535,8 @@ def ooc_cmd_zone_paranoia(client: ClientManager.Client, arg: str): which the server, with probability "player paranoia + zone paranoia", starts a timer of length a random number less than 150 seconds, after which it sends the user a phantom peek message if they are not blind and not staff, in an area that is not a lobby or private area, and they - have a valid character selected. A new phantom peek cycle is restarted regardless of success - after the old one expires. + have a participant character selected. A new phantom peek cycle is restarted regardless of + success after the old one expires. Returns an error if you are not watching a zone, or if the new zone paranoia level is not a number from -100 to 100. @@ -10598,7 +11544,7 @@ def ooc_cmd_zone_paranoia(client: ClientManager.Client, arg: str): /zone_paranoia PARAMETERS - : New intended player paranoia level + : New intended zone paranoia level EXAMPLES Assuming you are watching zome z0... @@ -10937,8 +11883,8 @@ def ooc_cmd_zone_unwatch(client: ClientManager.Client, arg: str): client.send_ooc('(X) As you were the last person in an area part of it or who was watching ' 'it, your zone has been deleted.') client.send_ooc_others('Zone `{}` was automatically ended as no one was in an ' - 'area part of it or was watching it anymore.' - .format(target_zone.get_id()), is_officer=True) + 'area part of it or was watching it anymore.' + .format(target_zone.get_id()), is_officer=True, in_hub=None) def ooc_cmd_zone_watch(client: ClientManager.Client, arg: str): @@ -10961,7 +11907,7 @@ def ooc_cmd_zone_watch(client: ClientManager.Client, arg: str): Constants.assert_command(client, arg, is_staff=True, parameters='=1') try: - target_zone = client.server.zone_manager.get_zone(arg) + target_zone = client.hub.zone_manager.get_zone(arg) except KeyError: raise ZoneError('`{}` is not a valid zone ID.'.format(arg)) @@ -11090,505 +12036,49 @@ def ooc_cmd_mod_narrate(client: ClientManager.Client, arg: str): c.send_ic(msg=arg, color=5, hide_character=1, bypass_text_replace=True) -def ooc_cmd_ambient(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) - Sets up the ambient sound effect of the current area. Players in the current area, and players - that later join the area, will be ordered to play the area ambient sound effect. - - SYNTAX - /ambient - - PARAMETERS - : Name of the ambient sound effect - - EXAMPLES - >>> /ambient wind.wav - Sets the ambient sound effect of the area to `wind.wav`. - """ - - Constants.assert_command(client, arg, is_staff=True, parameters='>0') - - client.area.ambient = arg - - for target in client.area.clients: - target.send_area_ambient(name=arg) - - client.send_ooc(f'You have set the ambient sound effect of your area to `{arg}`.') - client.send_ooc_others(f'The ambient sound effect of your area was set to `{arg}`.', - in_area=True, is_zstaff_flex=False) - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] set the ambient sound effect ' - f'of their area to `{arg}` ({client.area.id}).', is_zstaff_flex=True) - - -def ooc_cmd_ambient_end(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) - Clears the ambient sound effect of the current area. Players in the current area will be ordered - to stop playing the former area ambient sound effect, and players that later join the area will - not play the former area ambient sound effect. - Returns an error if no ambient sound effect is playing in the area. - - SYNTAX - /ambient - - PARAMETERS - None - - EXAMPLES - >>> /ambient_end - Clears the ambient sound effect of the area. - """ - - Constants.assert_command(client, arg, is_staff=True, parameters='=0') - - if not client.area.ambient: - raise ClientError('There already is no ambient sound effect in your area.') - - client.area.ambient = '' - - for target in client.area.clients: - target.send_area_ambient(name='') - - client.send_ooc('You have cleared the ambient sound effect of your area.') - client.send_ooc_others('The ambient sound effect of your area was cleared.', in_area=True, - is_zstaff_flex=False) - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] cleared the ambient sound ' - f'effect of their area ({client.area.id}).', is_zstaff_flex=True) - - -def ooc_cmd_ambient_info(client: ClientManager.Client, arg: str): +def ooc_cmd_exec(client: Union[ClientManager.Client, None], arg: str): """ - Displays the current area ambient sound effect. - Returns an error if no area ambient sound effect is playing. - - SYNTAX - /ambient_info + VERY DANGEROUS. SHOULD ONLY BE ENABLED FOR DEBUGGING. - PARAMETERS - None + DID I MENTION THIS IS VERY DANGEROUS? - EXAMPLES - Assuming the ambient sound effect of the current area is `wind.wav`... - >>> /ambient_info - Returns 'The current ambient sound effect of your area is `wind.wav`'. - """ - Constants.assert_command(client, arg, parameters='=0') + DO NOT ENABLE THIS FUNCTION UNLESS YOU KNOW WHAT YOU ARE DOING. - if not client.area.ambient: - raise ClientError('There already is no ambient sound effect in your area.') + I MEAN IT. - client.send_ooc(f'The current ambient sound effect of your area is `{client.area.ambient}`.') + PEOPLE WILL BREAK YOUR SERVER AND POSSIBLY THE HOST MACHINE IT IS ON IF YOU KEEP IT ON. + DO NOT BE STUPID. -def ooc_cmd_zone_ambient(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) - Sets up the ambient sound effect of all areas in the zone you are watching. Players in areas - part of the zone, and players that later join an area of the zone, will be ordered to play the - area ambient sound effect. - This command is equivalent to calling /ambient in every area of the zone you are watching. - GMs may still individually change or clear ambient sound effects for areas of the zone after - running the command, and such actions will override the "zone ambient". + Executes a Python expression and returns the evaluated expression. + If passed in a Python statement, it will execute code in the global environment. + Returns an error if the expression would raise an error in a normal Python environment. SYNTAX - /zone_ambient + /exec PARAMETERS - : Name of the ambient sound effect + EXAMPLES - >>> /zone_ambient wind.wav - Sets the ambient sound effect of all areas of the current zone to `wind.wav`. + /exec 1+1 + Returns 2 + /exec while True: client.send_ooc("Hi") + Commit sudoku """ - Constants.assert_command(client, arg, is_staff=True, parameters='>0') - - if not client.zone_watched: - raise ZoneError('You are not watching a zone.') - - zone = client.zone_watched + # IF YOU WANT TO DISABLE /exec: SET debug TO 0 (debug = 0) + # IF YOU WANT TO ENABLE /exec: SET debug TO 1 (debug = 1) - targets = zone.get_players() - client.send_ooc(f'You have set the ambient sound effect of all areas of your zone to `{arg}`.') - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] have set the ambient sound ' - f'effect of all areas of your zone to `{arg}` ({client.area.id}).', - is_zstaff=True) + debug = 0 + if not debug: + return None - for c in targets: - c.send_area_ambient(name=arg) - for a in zone.get_areas(): - a.ambient = arg + if not client: + # client is None for server.check_exec_active() + return debug - -def ooc_cmd_zone_ambient_end(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) - Clears the ambient sound effect of all areas of the zone you are watching. Players in an area - part of the zone will be ordered to stop playing the former area ambient sound effect, and - players that later join some area of the zone will not play the former area ambient sound - effect. - This command is equivalent to calling /ambient_end in every area of the zone you are watching, - without displaying error messages if it happened to be the case no ambient sound effect was set - for some (or all) of the areas of the zone. - GMs may still individually change or clear ambient sound effects for areas of the zone after - running the command, and such actions will override the "zone ambient". - Returns an error if you are not watching a zone. - - SYNTAX - /zone_ambient - - PARAMETERS - None - - EXAMPLES - >>> /zone_ambient_end - Clears the ambient sound effect of all areas of the zone you are watching. - """ - - Constants.assert_command(client, arg, is_staff=True, parameters='=0') - - if not client.zone_watched: - raise ZoneError('You are not watching a zone.') - - zone = client.zone_watched - - targets = zone.get_players() - client.send_ooc('You have removed the area ambient sound effect of all areas of your zone.') - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] removed the area ambient sound ' - f'effect of all areas of your zone ({client.area.id}).', is_zstaff=True) - - for c in targets: - c.send_area_ambient(name='') - for a in zone.get_areas(): - a.ambient = '' - - -def ooc_cmd_sneakself(client: ClientManager.Client, arg: str): - """ (STAFF ONLY+VARYING REQUIREMENTS) - Makes all opened multiclients be sneaked without having to manually sneak them. - Opened multiclients that are already sneaked are unaffected. - If a multiclient is in a private area, or in a lobby area and you are not an officer, or is - already sneaked, the sneak will fail for that multiclient. - Returns an error if no opened multiclients can successfully be sneaked. - - SYNTAX - /sneakself - - EXAMPLES - If user with client ID 0 is GM has multiclients with ID 1 and 3, neither sneaked, and runs... - >>> /sneakself - Sneaks clients 0, 1 and 3. - """ - - Constants.assert_command(client, arg, is_staff=True) - - targets = [c for c in client.get_multiclients() if c.is_visible] - targets = [c for c in targets if not c.area.private_area] - if not client.is_officer(): - targets = [c for c in targets if c.area.lobby_area] - if not targets: - raise ClientError('No opened clients can be sneaked.') - - # Sneak matching targets - for c in targets: - c.change_visibility(False) - - client.send_ooc("You sneaked all of your valid multiclients.") - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] sneaked all their valid ' - f'multiclients [{client.id}] ({client.area.id}).', - not_to=set(targets), is_zstaff=True) - - non_targets = [c for c in client.get_multiclients() if c not in targets] - if non_targets: - s_non_targets = Constants.cjoin([f'{c.displayname} [{c.id}]' for c in non_targets]) - client.send_ooc(f'The following clients could not be sneaked: {s_non_targets}') - - -def ooc_cmd_mindreader(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) - Toggles a client by ID being a mind reader or not (i.e. can read all thoughts caused by /think, - not just those initiated by the player), or yourself if not given an argument. - Returns an error if the given identifier does not correspond to a user. - - SYNTAX - /mindreader - /mindreader - - OPTIONAL PARAMETERS - {client_id}: Client identifier (number in brackets in /getarea) - - EXAMPLE - Assuming a user with client ID 0 starts as not being a mind reader... - >>> /mindreader 0 - This user can now read all thoughts. - >>> /mindreader 0 - This user can no longer read thoughts not initiated by the user. - """ - - Constants.assert_command(client, arg, is_staff=True, parameters='<2') - - # Invert current mindreader status of matching targets - if not arg: - target = client - else: - target = Constants.parse_id(client, arg) - target.is_mindreader = not target.is_mindreader - - status = {False: 'no longer', True: 'now'} - status2 = {False: 'no longer a', True: 'a'} - if client != target: - client.send_ooc(f'{target.displayname} ({target.id}) is {status[target.is_mindreader]} a ' - f'mind reader.') - client.send_ooc_others(f'(X) {client.displayname} ({client.id}) made {target.displayname} ' - f'({target.id}) be {status2[target.is_mindreader]} mind reader ' - f'({client.area.id}).', is_zstaff_flex=True) - target.send_ooc(f'You are {status[target.is_transient]} a mind reader.') - else: - client.send_ooc(f'You made yourself be {status2[target.is_mindreader]} mind reader.') - client.send_ooc_others(f'(X) {client.displayname} ({client.id}) made themselves be ' - f'{status2[target.is_mindreader]} mind reader ' - f'({client.area.id}).', is_zstaff_flex=True) - - -def ooc_cmd_bg_list(client: ClientManager.Client, arg: str): - """ (OFFICER ONLY) - Sets the server's current background list (what backgrounds areas may normally use at any given - time). - If given no arguments, it will return the background list to its original value - (in config/backgrounds.yaml). - Returns an error if the given background list name included relative directories, - was not found, caused an OS error when loading, or raised a YAML or asset syntax error when - loading. - - SYNTAX - /bg_list - - PARAMETERS - : Name of the intended background list - - EXAMPLES - >>> /bg_list beach - Load the "beach" background list. - >>> /bg_list - Reset the background list to its original value. - """ - - Constants.assert_command(client, arg, is_officer=True) - - client.server.background_manager.command_list_load(client, arg) - - -def ooc_cmd_bg_list_info(client: ClientManager.Client, arg: str): - """ (OFFICER ONLY) - Returns the current background list. - - SYNTAX - /bg_list_info - - PARAMETERS - None - - EXAMPLES - >>> /bg_list_info - May return something like this: - | $H: The current background list is the custom list `custom`. - """ - - Constants.assert_command(client, arg, is_officer=True, parameters='=0') - - client.server.background_manager.command_list_info(client) - - -def ooc_cmd_char_list(client: ClientManager.Client, arg: str): - """ (OFFICER ONLY) - Sets the server's current character list (what characters a player may use at any given time). - If given no arguments, it will return the character list to its original value - (in config/characters.yaml). - Returns an error if the given character list name included relative directories, - was not found, caused an OS error when loading, or raised a YAML or asset syntax error when - loading. - - SYNTAX - /char_list - - PARAMETERS - : Name of the intended character list - - EXAMPLES - >>> /char_list Transylvania - Load the "Transylvania" character list. - >>> /char_list - Reset the character list to its original value. - """ - - Constants.assert_command(client, arg, is_officer=True) - - client.server.character_manager.command_list_load(client, arg) - - -def ooc_cmd_char_list_info(client: ClientManager.Client, arg: str): - """ (OFFICER ONLY) - Returns the current character list. - - SYNTAX - /char_list_info - - PARAMETERS - None - - EXAMPLES - >>> /char_list_info - May return something like this: - | $H: The current character list is the custom list `custom`. - """ - - Constants.assert_command(client, arg, is_officer=True, parameters='=0') - - client.server.character_manager.command_list_info(client) - - -def ooc_cmd_area_list_info(client: ClientManager.Client, arg: str): - """ (OFFICER ONLY) - Returns the current area list. - - SYNTAX - /area_list_info - - PARAMETERS - None - - EXAMPLES - >>> /area_list_info - May return something like this: - | $H: The current area list is the custom list `beach`. - """ - - Constants.assert_command(client, arg, is_officer=True, parameters='=0') - - client.server.area_manager.command_list_info(client) - - -def ooc_cmd_music_list_info(client: ClientManager.Client, arg: str): - """ - Returns your current music list. - - SYNTAX - /music_list_info - - PARAMETERS - None - - EXAMPLES - >>> /music_list_info - May return something like this: - | $H: The current music list is the custom list `trial`. - """ - - Constants.assert_command(client, arg, parameters='=0') - - client.music_manager.command_list_info(client) - - -def ooc_cmd_bg_period(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) - Changes the background of the current area associated with the given period. - Returns an error if area background is locked and you are unathorized or if the sought - background does not exist. - - SYNTAX - /bg_period - - PARAMETERS - : Period name - : New background name, possibly with spaces (e.g. Principal's Room) - - EXAMPLES - >>> /bg_period night Beach (night) - Changes background to Beach (night) whenever the area has a night period active. - """ - - Constants.assert_command(client, arg, is_staff=True, parameters='>1') - if not client.is_mod and client.area.bg_lock: - raise AreaError("This area's background is locked.") - - args = arg.split() - tod_name = args[0] - bg_name = ' '.join(args[1:]) - - client.area.change_background_tod(bg_name, tod_name, validate=False) - client.send_ooc(f'You changed the background associated with period `{tod_name}` to ' - f'`{bg_name}`.') - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] changed the background ' - f'associated with period `{tod_name}` to `{bg_name}`.', - is_zstaff_flex=True) - logger.log_server('[{}][{}]Changed background associated with period `{}` to {}' - .format(client.area.id, client.get_char_name(), tod_name, bg_name), client) - - -def ooc_cmd_bg_period_end(client: ClientManager.Client, arg: str): - """ (STAFF ONLY) - Removes the background of the current area associated with the given period - Returns an error if area background is locked and you are unathorized or if the sought - background does not exist. - - SYNTAX - /bg_period_end - - PARAMETERS - : Period name - - EXAMPLES - >>> /bg_period_end night - Removes the background associated with the night period of the current area. - """ - - Constants.assert_command(client, arg, is_staff=True, parameters='=1') - if not client.is_mod and client.area.bg_lock: - raise AreaError("This area's background is locked.") - - client.area.change_background_tod('', arg, validate=False) - client.send_ooc(f'You removed the background associated with period `{arg}`.') - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] removed the background ' - f'associated with period `{arg}`.', - is_zstaff_flex=True) - logger.log_server('[{}][{}]Removed background associated with period `{}`' - .format(client.area.id, client.get_char_name(), arg), client) - - -def ooc_cmd_exec(client: ClientManager.Client, arg: str): - """ - VERY DANGEROUS. SHOULD ONLY BE ENABLED FOR DEBUGGING. - - DID I MENTION THIS IS VERY DANGEROUS? - - DO NOT ENABLE THIS FUNCTION UNLESS YOU KNOW WHAT YOU ARE DOING. - - I MEAN IT. - - PEOPLE WILL BREAK YOUR SERVER AND POSSIBLY THE HOST MACHINE IT IS ON IF YOU KEEP IT ON. - - DO NOT BE STUPID. - - Executes a Python expression and returns the evaluated expression. - If passed in a Python statement, it will execute code in the global environment. - Returns an error if the expression would raise an error in a normal Python environment. - - SYNTAX - /exec - - PARAMETERS - - - EXAMPLES - /exec 1+1 - Returns 2 - /exec while True: client.send_ooc("Hi") - Commit sudoku - """ - - # IF YOU WANT TO DISABLE /exec: SET debug TO 0 (debug = 0) - # IF YOU WANT TO ENABLE /exec: SET debug TO 1 (debug = 1) - - debug = 0 - if not debug: - return None + # Code after this point assumes debug mode is on!!! logger.log_print("Attempting to run instruction {}".format(arg)) try: diff --git a/server/commands_alt.py b/server/commands_alt.py index aeecb7ff7..b5a2c7bac 100644 --- a/server/commands_alt.py +++ b/server/commands_alt.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -60,21 +61,7 @@ def get_command_deprecated(command): 'showname_areas': 'getareas', 'ga': 'getarea', 'gas': 'getareas', + 'area_kick': 'summon', } -command_deprecated = { - 'clock_cancel': 'clock_end', - 'lurk_cancel': 'lurk_end', - 'party_disband': 'party_end', - 'party_members': 'party_info', - 'timer_cancel': 'timer_end', - 'zone_delete': 'zone_end', - 'area_list': 'map', - 'area_lists': 'map_list', - 'cleardoc': 'doc_clear', - 'reload': 'char_reload', - 'charselect': 'char_select', - 'defaultarea': 'area_default', - 'currentmusic': 'play_info', - 'randommusic': 'play_random', -} +command_deprecated = dict() diff --git a/server/constants.py b/server/constants.py index 2164de619..8483eb103 100644 --- a/server/constants.py +++ b/server/constants.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -17,33 +18,39 @@ # along with this program. If not, see . from __future__ import annotations -from io import TextIOWrapper -from typing import Awaitable, Any, Callable, Iterable, List, Optional, Set, Tuple -import typing -if typing.TYPE_CHECKING: - # Avoid circular referencing - from server.area_manager import AreaManager - from server.client_manager import ClientManager - from server.tsuserver import TsuserverDR import asyncio import functools import errno +import hashlib +import hmac import os import pathlib import random import re +import secrets import sys import tempfile import time +import typing import warnings import yaml from enum import Enum +from io import TextIOWrapper +from typing import Awaitable, Any, Callable, Iterable, List, Set, Tuple, Union from server.exceptions import ClientError, ServerError, ArgumentError, AreaError from server.exceptions import TsuserverException +if typing.TYPE_CHECKING: + from asyncio.proactor_events import _ProactorSocketTransport + + # Avoid circular referencing + from server.area_manager import AreaManager + from server.client_manager import ClientManager + from server.hub_manager import _Hub + from server.tsuserver import TsuserverDR class ArgType(Enum): STR = 1 @@ -240,12 +247,8 @@ def file_exists_or_creatable(pathname: str) -> bool: if not FileValidity.is_path_exists_or_creatable(pathname): return False - try: - if pathlib.Path(pathname).is_file(): - return True - except OSError: - # 3.7 in Windows raises an OSError for stuff like `con.yaml` here - return False + if pathlib.Path(pathname).is_file(): + return True # If execution makes it here, we are in one of two situations # pathname exists but is not a file @@ -346,7 +349,7 @@ def includes_relative_directories(path: str) -> bool: return ('.' in folders or '..' in folders) @staticmethod - def includes_omniwhy_exploit(name: str) -> bool: + def is_aoprotocol_injection_vulnerable(name: str) -> bool: return name.startswith('%') or '#' in name @staticmethod @@ -508,14 +511,27 @@ def assert_command(client: ClientManager.Client, arg: str, is_staff=None, is_off raise ArgumentError(error[0].format(error[1], 's' if error[1] != 1 else '')) @staticmethod - def build_cond(sender: ClientManager.Client, is_staff=None, is_officer=None, is_mod=None, - in_area=None, pred=None, part_of=None, not_to=None, to_blind=None, to_deaf=None, - is_zstaff=None, is_zstaff_flex=None) -> Callable[[ClientManager.Client], bool]: + def build_cond( + sender: ClientManager.Client, + is_staff: Union[bool, None] = None, + is_officer: Union[bool, None] = None, + is_mod: Union[bool, None] = None, + in_hub: Union[bool, _Hub, Set[_Hub], None] = None, + in_area: Union[bool, AreaManager.Area, Set[AreaManager.Area], None] = None, + not_to: Union[Set[ClientManager.Client], None] = None, + part_of: Union[Set[ClientManager.Client], None] = None, + to_blind: Union[bool, None] = None, + to_deaf: Union[bool, None] = None, + is_zstaff: Union[bool, AreaManager.Area, None] = None, + is_zstaff_flex: Union[bool, AreaManager.Area, None] = None, + pred: Callable[[ClientManager.Client], bool] = None, + ) -> Callable[[ClientManager.Client], bool]: """ Acceptable conditions: is_staff: If target is GM, CM or Mod is_officer: If target is CM or Mod is_mod: If target is Mod + in_hub: If target is in client's hub, or some particular hub in_area: If target is in client's area, or some particular area part_of: If target is an element of this set not_to: If target is not in a set of clients that are filtered out @@ -558,6 +574,19 @@ def build_cond(sender: ClientManager.Client, is_staff=None, is_officer=None, is_ else: raise KeyError('Invalid argument for build_cond is_mod: {}'.format(is_mod)) + if in_hub is True: + conditions.append(lambda c: c.hub == sender.hub) + elif in_hub is False: + conditions.append(lambda c: c.area != sender.hub) + elif isinstance(in_hub, type(sender.hub)): # Lazy way of finding if in_hub is hub obj + conditions.append(lambda c: c.hub == in_hub) + elif isinstance(in_hub, set): + conditions.append(lambda c: c.hub in in_hub) + elif in_hub is None: + pass + else: + raise KeyError('Invalid argument for build_cond in_hub: {}'.format(in_hub)) + if in_area is True: conditions.append(lambda c: c.area == sender.area) elif in_area is False: @@ -616,7 +645,7 @@ def build_cond(sender: ClientManager.Client, is_staff=None, is_officer=None, is_ conditions.append(lambda c: (c.zone_watched != sender.area.in_zone)) else: conditions.append(lambda c: False) - elif isinstance(is_zstaff, sender.server.area_manager.Area): + elif isinstance(is_zstaff, sender.hub.area_manager.Area): # Only staff members who are watching the area's zone will receive it, PROVIDED the area # is part of a zone. Otherwise, NO notification is sent. target_zone = is_zstaff.in_zone @@ -653,7 +682,7 @@ def build_cond(sender: ClientManager.Client, is_staff=None, is_officer=None, is_ else: condition1 = lambda c: False conditions.append(lambda c: condition1(c) or not c.is_staff()) - elif isinstance(is_zstaff_flex, sender.server.area_manager.Area): + elif isinstance(is_zstaff_flex, sender.hub.area_manager.Area): # Only staff members who are watching the area's zone will receive it, PROVIDED the area # is part of a zone. Otherwise, NO notification is sent. target_zone = is_zstaff_flex.in_zone @@ -746,7 +775,7 @@ def dice_roll(arg: str, command_type: str, server: TsuserverDR) -> Tuple[str, in # mid_roll: result after modifiers (if any) have been applied to original roll # final_roll: result after previous result was capped between 1 and max_numfaces - raw_roll = str(server.random.randint(1, num_faces)) + raw_roll = str(random.randint(1, num_faces)) if modifiers == '': aux_modifier = '' mid_roll = int(raw_roll) @@ -836,40 +865,6 @@ def fix_and_setify(csv_values: str) -> Set[str]: return set() return set(split_values) - @staticmethod - def gimp_message(): - Constants.warn_deprecated('Constants.gimp_message()', - 'random.choice(server.gimp_list)', - '4.4') - message = ['ERP IS BAN', - 'I\'m fucking gimped because I\'m both autistic and a retard!', - 'HELP ME', - 'Boy, I sure do love Dia, the best admin, and the cutest!!!!!', - 'I\'M SEVERELY AUTISTIC!!!!', - '[PEES FREELY]', - 'KILL ME', - 'I found this place on reddit XD', - '(((((case????)))))', - 'Anyone else a fan of MLP?', - 'does this server have sans from undertale?', - 'what does call mod do', - 'does anyone have a miiverse account?', - 'Drop me a PM if you want to ERP', - 'Join my discord server please', - 'can I have mod pls?', - 'why is everyone a missingo?', - 'how 2 change areas?', - 'does anyone want to check out my tumblr? :3', - '19 years of perfection, i don\'t play games to fucking lose', - 'nah... your taunts are fucking useless... only defeat angers me... by trying ' - 'to taunt just earns you my pitty', - 'When do we remove dangits', - 'MODS STOP GIMPING ME', - 'Please don\'t say things like ni**er and f**k it\'s very rude and I don\'t ' - 'like it', - 'PLAY NORMIES PLS'] - return random.choice(message) - @staticmethod def gagged_message() -> str: length = random.randint(5, 9) @@ -909,13 +904,13 @@ def parse_area_names(client: ClientManager.Client, # wants ',\' as part of their actual area name. If you are that person... just... why try: target = areas[i].replace(',\\', ',') - area_list.append(client.server.area_manager.get_area_by_name(target)) + area_list.append(client.hub.area_manager.get_area_by_name(target)) except AreaError: try: - area_list.append(client.server.area_manager.get_area_by_name(areas[i])) + area_list.append(client.hub.area_manager.get_area_by_name(areas[i])) except AreaError: try: - area_list.append(client.server.area_manager.get_area_by_id(int(areas[i]))) + area_list.append(client.hub.area_manager.get_area_by_id(int(areas[i]))) except Exception: raise ArgumentError('Could not parse area `{}`.'.format(areas[i])) return area_list @@ -990,13 +985,6 @@ def parse_id_or_ipid(client: ClientManager.Client, raise ArgumentError('No targets found.') - @staticmethod - def parse_passage_lock(client, areas, bilock=False): - Constants.warn_deprecated('Constants.parse_passage_lock()', - 'Constants.change_passage_lock()', - '4.4') - client.server.area_manager.change_passage_lock(client, areas, bilock=bilock) - @staticmethod def parse_time_length(time_length: str) -> float: """ @@ -1139,11 +1127,9 @@ def check_exception(_client, _future): if not exception: return - if isinstance(exception, (KeyboardInterrupt, asyncio.CancelledError)): - # exception may only be asyncio.CancelledError in Python 3.7 or lower - # In Python 3.8 it would be raised as an exception and caught in the - # earlier try except. + if isinstance(exception, (KeyboardInterrupt, )): return + try: if not (_client and isinstance(exception, TsuserverException)): raise exception @@ -1263,3 +1249,69 @@ def contains_illegal_characters(text: str) -> bool: if char in text: return True return False + + @staticmethod + def get_first_area_list_item(view_name: str, hub: _Hub, area: AreaManager.Area) -> str: + if view_name == 'AREA': + return (f'|| GO TO {view_name} VIEW\n' + f'|| YOU ARE HERE:\n' + f'|| Hub {hub.get_id()[1:]}, Area {area.id}\n') + elif view_name == 'HUB': + return (f'|| GO TO {view_name} VIEW') + else: + raise RuntimeError(f'Invalid view {view_name}') + + @staticmethod + def secure_eq(a: str, b: str) -> bool: + """ + Return whether a and b are the same, such that timing attacks are difficult to perform. + + Parameters + ---------- + a : str + First element. + b : str + Second element. + + Returns + ------- + bool + Whether `a == b`. + """ + + key = secrets.token_hex(16) + byte_key = bytes(key, 'utf-8') + + mes_a = a.encode('utf-8') + mes_b = b.encode('utf-8') + + enc_a = hmac.new(byte_key, mes_a, hashlib.sha256) + enc_b = hmac.new(byte_key, mes_b, hashlib.sha256) + + return hmac.compare_digest( + enc_a.hexdigest(), + enc_b.hexdigest() + ) + + @staticmethod + def get_ip_of_transport(transport: Union[_ProactorSocketTransport, None]) -> str: + if not transport: + return "127.0.0.1" + + return transport.get_extra_info('peername')[0] + + @staticmethod + async def await_cancellation(old_task: asyncio.Task): + # Wait until it is able to properly retrieve the cancellation exception + try: + await old_task + except asyncio.CancelledError: + pass + + @staticmethod + async def do_nothing(): + while True: + try: + await asyncio.sleep(1) + except KeyboardInterrupt: + raise diff --git a/server/evidence.py b/server/evidence.py index a2ad16b7b..6dd535789 100644 --- a/server/evidence.py +++ b/server/evidence.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -89,16 +90,16 @@ def create_evi_list(self, client): evi_list.append(self.evidences[i].to_string()) return nums_list, evi_list - def del_evidence(self, client, id): + def del_evidence(self, client, evi_id): if self.login(client): - self.evidences.pop(id) + self.evidences.pop(evi_id) - def edit_evidence(self, client, id, arg): + def edit_evidence(self, client, evi_id, arg): if self.login(client): if client.area.evidence_mod == 'HiddenCM' and self.correct_format(client, arg[1]): - self.evidences[id] = self.Evidence(arg[0], arg[1][14:], arg[2], arg[1][9:12]) + self.evidences[evi_id] = self.Evidence(arg[0], arg[1][14:], arg[2], arg[1][9:12]) return if client.area.evidence_mod == 'HiddenCM': client.send_ooc('You entered a wrong pos.') return - self.evidences[id] = self.Evidence(arg[0], arg[1], arg[2], arg[3]) + self.evidences[evi_id] = self.Evidence(arg[0], arg[1], arg[2], arg[3]) diff --git a/server/exceptions.py b/server/exceptions.py index ed2e874df..afcf7f80b 100644 --- a/server/exceptions.py +++ b/server/exceptions.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -47,7 +48,15 @@ def recreate_subexceptions(cls): than TsuserverException. """ - subexceptions = [item for item in cls.__dict__.keys() if not item.startswith('__')] + #subexceptions = [item for item in cls.__dict__.keys() if not item.startswith('__')] + + current_class = cls + subexceptions = set() + while current_class != TsuserverException: + subexceptions |= {item for item in current_class.__dict__.keys() + if not item.startswith('__')} + current_class = current_class.__bases__[0] + for subexception_name in subexceptions: fullname = '{}.{}'.format(cls.__name__, subexception_name) setattr(cls, subexception_name, type(fullname, (cls, ), dict())) @@ -66,12 +75,6 @@ class InvalidInboundPacketArguments(TsuserverException): pass -@recreate_subexceptions -class BackgroundError(TsuserverException): - class BackgroundNotFoundError(TsuserverException): - pass - - @recreate_subexceptions class AreaError(TsuserverException): class ClientNotFound(TsuserverException): @@ -339,9 +342,13 @@ class AreaNotInGameError(GameError): class AreaHitGameConcurrentLimitError(TsuserverException): pass +@recreate_subexceptions +class HubbedGameError(GameWithAreasError): + class AreaNotInHubError(GameWithAreasError): + pass @recreate_subexceptions -class TrialError(GameWithAreasError): +class TrialError(HubbedGameError): class AreaDisallowsBulletsError(GameWithAreasError): pass @@ -354,17 +361,30 @@ class InfluenceIsInvalidError(GameWithAreasError): class FocusIsInvalidError(GameWithAreasError): pass +@recreate_subexceptions +class TrialMinigameError(HubbedGameError): + pass @recreate_subexceptions -class NonStopDebateError(GameWithAreasError): - class NSDAlreadyInModeError(GameWithAreasError): +class NonStopDebateError(TrialMinigameError): + class NSDAlreadyInModeError(TrialMinigameError): + pass + + class NSDNotInModeError(TrialMinigameError): pass - class NSDNotInModeError(GameWithAreasError): + class NSDNoMessagesError(TrialMinigameError): pass - class NSDNoMessagesError(GameWithAreasError): + class TimersAlreadySetupError(TrialMinigameError): pass - class TimersAlreadySetupError(GameWithAreasError): +@recreate_subexceptions +class HubError(GameWithAreasError): + class ManagerCannotManageeNoManagees(GameWithAreasError): + pass + +@recreate_subexceptions +class TaskError(TsuserverException): + class TaskNotFoundError(TsuserverException): pass diff --git a/server/game_manager.py b/server/game_manager.py index 6a31bc230..59c36bf95 100644 --- a/server/game_manager.py +++ b/server/game_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -34,16 +35,24 @@ """ -from server.constants import Constants +from __future__ import annotations + +import typing +from typing import Any, Callable, Dict, Set, Tuple, Type, Union + from server.exceptions import GameError, PlayerGroupError, TimerError -from server.playergroup_manager import PlayerGroup, PlayerGroupManager -from server.timer_manager import TimerManager +from server.playergroup_manager import _PlayerGroup, PlayerGroupManager +from server.timer_manager import Timer, TimerManager from server.subscriber import Listener, Publisher +if typing.TYPE_CHECKING: + from server.client_manager import ClientManager + from server.tsuserver import TsuserverDR -class _Team(PlayerGroup): +class _Team(_PlayerGroup): """ - Teams are player groups with a fixed concurrent player membership limit of 1. + Teams are player groups that have a game associated with them. Users may only be added as + players of the team if they are a player of the game. """ # (Private) Attributes @@ -53,38 +62,38 @@ class _Team(PlayerGroup): def __init__(self, server, manager, playergroup_id, player_limit=None, player_concurrent_limit=None, require_invitations=False, require_players=True, - require_leaders=True): + require_leaders=True, game: _Game = None): """ - Create a new player group. + Create a new team. Parameters ---------- server : TsuserverDR - Server the player group belongs to. + Server the team belongs to. manager : PlayerGroupManager - Manager for this player group. + Manager for this team. playergroup_id : str - Identifier of the player group. + Identifier of the team. game : _Game - Game of this player group. - player_limit : int or None, optional - If an int, it is the maximum number of players the player group supports. If None, - it indicates the player group has no player limit. Defaults to None. - player_concurrent_limit : int or None, optional - If an int, it is the maximum number of player groups managed by `manager` that any - player of this group may belong to, including this group. If None, it indicates - that this group does not care about how many other player groups managed by + Game of this team. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the team supports. If None, + it indicates the team has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of teams managed by `manager` that any + player of this team may belong to, including this team. If None, it indicates + that this team does not care about how many other teams managed by `manager` each of its players belongs to. It is always overwritten by 1 (a player - may not be in another group managed by `manager` while in this group). + may not be in another team managed by `manager` while in this team). require_invitations : bool, optional - If True, players can only be added to the group if they were previously invited. If + If True, players can only be added to the team if they were previously invited. If False, no checking for invitations is performed. Defaults to False. require_players : bool, optional - If True, if at any point the group has no players left, the group will + If True, if at any point the team has no players left, the team will automatically be deleted. If False, no such automatic deletion will happen. Defaults to True. require_leaders : bool, optional - If True, if at any point the group has no leaders left, the group will choose a + If True, if at any point the team has no leaders left, the team will choose a leader among any remaining players left; if no players are left, the next player added will be made leader. If False, no such automatic assignment will happen. Defaults to True. @@ -92,20 +101,21 @@ def __init__(self, server, manager, playergroup_id, player_limit=None, """ super().__init__(server, manager, playergroup_id, player_limit=player_limit, - player_concurrent_limit=1, # Teams allow 1 concurrent player membership + player_concurrent_limit=player_concurrent_limit, require_invitations=require_invitations, require_players=require_players, require_leaders=require_leaders) - self._game = None + self._game = game + self.manager: GameManager # Setting for typing def add_player(self, user): """ - Make a user a player of the player group. By default this player will not be a + Make a user a player of the team. By default this player will not be a leader. Parameters ---------- user : ClientManager.Client - User to add to the player group. + User to add to the team. cond : types.LambdaType: ClientManager.Client -> bool, optional Condition that the player to add must satisfy. If the user fails this condition, they will not be added. Defaults to None (no checked conditions). @@ -113,15 +123,15 @@ def add_player(self, user): Raises ------ PlayerGroupError.UserNotInvitedError - If the group requires players be invited to be added and the user is not invited. + If the team requires players be invited to be added and the user is not invited. PlayerGroupError.UserAlreadyPlayerError - If the user to add is already a user of the player group. + If the user to add is already a user of the team. PlayerGroupError.GroupIsFullError - If the group reached its player limit. + If the team reached its player limit. PlayerGroupError.UserHitGroupConcurrentLimitError. If the player has reached any of the groups it belongs to managed by this player - group's manager concurrent player membership limit, or by virtue of joining this group - will violate this group's concurrent player membership limit. + team's manager concurrent player membership limit, or by virtue of joining this team + will violate this team's concurrent player membership limit. PlayerGroupError.UserNotPlayerError If the user to add is not a player of the game. @@ -132,235 +142,125 @@ def add_player(self, user): super().add_player(user) - -class _Game(): +class _GameTrivialInherited(_PlayerGroup): + """ + This class should not be instantiated. """ - A mutable data type for games. - - Games are groups of users (called players) with an ID, that may also manage some timers - and teams. - Some players of the game (possibly none) may become leaders. A player that is not a leader - is called regular. Each game may have a player limit (beyond which no new players may be added), - may require that it never loses all its players as soon as it gets its first one (or else it - is automatically deleted) and may require that if it has at least one player, then that there - is at least one leader (or else one is automatically chosen between all players). Each of these - games may also impose a concurrent player membership limit, so that every user that is a player - of it is at most of that many games managed by this game's manager. Each game may also - require all its players have characters when trying to join the game, as well as remove any - player that switches to a non-character. + def get_id(self) -> str: + """ + Return the ID of this game. - Each of the timers a game manages are timer_manager.Timers. + Returns + ------- + str + The ID. - For each managed team, its players must also be players of this game. + """ - Once a game is scheduled for deletion, its manager will no longer recognize it as a game - it is managing (it will unmanage it), so no further mutator public method calls would be - allowed on the game. + return super().get_id() - Each game also has a standard listener. By default the game subscribes to all its players' - updates. + def get_numerical_id(self) -> int: + """ + Return the numerical portion of the ID of this game. - Attributes - ---------- - listener : Listener - Standard listener of the game. + Returns + ------- + int + Numerical portion of the ID. + """ - Callback Methods - ---------------- - _on_client_inbound_ms_check - Method to perform once a player of the game wants to send an IC message. - _on_client_inbound_ms_final - Method to perform once a player of the game sends an IC message. - _on_client_change_character - Method to perform once a player of the game has changed character. - _on_client_destroyed - Method to perform once a player of the game is destroyed. + return super().get_numerical_id() - """ + def get_name(self) -> str: + """ + Get the name of the game. - # (Private) Attributes - # -------------------- - # _server : TsuserverDR - # Server the game belongs to. - # _manager : GameManager - # Manager for this game. - # _game_id : str - # Identifier for this game. - # _require_character : bool - # If False, players without a character will not be allowed to join the game, and players - # that switch to something other than a character will be automatically removed from the - # game. If False, no such checks are made. - # _team_manager : PlayerGroupManager - # Internal manager that handles the teams of the game. - # _timer_manager: TimerManager - # Internal manager that handles the timers of the game. - # _playergroup: PlayerGroup - # Internal playergroup that implements the player features of the game. + Returns + ------- + str + Name. + """ - # Invariants - # ---------- - # 1. All players part of a team managed by this game are players of the game. - # 2. `self._unmanaged == self._playergroup._unmanaged`. - # 3. For each player of the game, the game is subscribed to it. - # 4. If the game requires its players have characters, all its players do have characters. - # 5. Each internal structure satisfies its invariants. + return super().get_name() - def __init__(self, server, manager, game_id, player_limit=None, - player_concurrent_limit=None, require_invitations=False, require_players=True, - require_leaders=True, require_character=False, team_limit=None, - timer_limit=None, playergroup_manager=None): + def set_name(self, name: str): """ - Create a new game. A game should not be fully initialized anywhere else other than - some manager code, as otherwise the manager will not recognize the game. + Set the name of the game. Parameters ---------- - server : TsuserverDR - Server the game belongs to. - manager : GameManager - Manager for this game. - game_id : str - Identifier of the game. - player_limit : int or None, optional - If an int, it is the maximum number of players the game supports. If None, it - indicates the game has no player limit. Defaults to None. - player_concurrent_limit : int or None, optional - If an int, it is the maximum number of games managed by `manager` that any - player of this game may belong to, including this game. If None, it indicates - that this game does not care about how many other games managed by `manager` each - of its players belongs to. Defaults to None. - require_invitation : bool, optional - If True, players can only be added to the game if they were previously invited. If - False, no checking for invitations is performed. Defaults to False. - require_players : bool, optional - If True, if at any point the game has no players left, the game will - automatically be deleted. If False, no such automatic deletion will happen. - Defaults to True. - require_leaders : bool, optional - If True, if at any point the game has no leaders left, the game will choose a - leader among any remaining players left; if no players are left, the next player - added will be made leader. If False, no such automatic assignment will happen. - Defaults to True. - require_character : bool, optional - If False, players without a character will not be allowed to join the game, and - players that switch to something other than a character will be automatically - removed from the game. If False, no such checks are made. A player without a - character is considered one where player.has_character() returns False. Defaults to - False. - team_limit : int or None, optional - If an int, it is the maximum number of teams the game supports. If None, it - indicates the game has no team limit. Defaults to None. - timer_limit : int or None, optional - If an int, it is the maximum number of timers the game supports. If None, it - indicates the game has no timer limit. Defaults to None. - playergroup_manager : PlayerGroupManager, optional - The internal playergroup manager of the game manager. Access to this value is - limited exclusively to this __init__, and is only to initialize the internal - player group of the game. - - Raises - ------ - GameError.ManagerTooManyGamesError - If the manager is already managing its maximum number of games. - + name : str + Name. """ - self._server = server - self._manager = manager - self._game_id = game_id - self._require_character = require_character - self._unmanaged = False - - self._team_manager = PlayerGroupManager(server, playergroup_limit=team_limit, - default_playergroup_type=_Team) - self._timer_manager = TimerManager(server, timer_limit=timer_limit) - # Creator is to be added in the manager - try: - group = playergroup_manager.new_group(creator=None, - player_limit=player_limit, - player_concurrent_limit=player_concurrent_limit, - require_invitations=require_invitations, - require_players=require_players, - require_leaders=require_leaders) - except PlayerGroupError.ManagerTooManyGroupsError: - raise GameError.ManagerTooManyGamesError - - self._playergroup = group - - self.publisher = Publisher(self) - # Implementation detail: the callbacks of the internal objects of the game are (to be) - # ignored. - self.listener = Listener(self, { - 'client_inbound_ms_final': self._on_client_inbound_ms_final, - 'client_inbound_ms_check': self._on_client_inbound_ms_check, - 'client_change_character': self._on_client_change_character, - 'client_destroyed': self._on_client_destroyed, - }) + self.unchecked_set_name(name) + self.manager._check_structure() - def get_id(self) -> str: + def unchecked_set_name(self, name: str): """ - Return the ID of this game. + Set the name of the game. - Returns - ------- - str - The ID. + This method does not assert structural integrity. + Parameters + ---------- + name : str + Name. """ - # Development note: This is NOT the ID of the internal player group, but the ID of the - # game itself. To facilitate your life, these two should be made the same. - return self._game_id + super().unchecked_set_name(name) - def get_name(self) -> str: + def get_player_limit(self) -> Union[int, None]: """ - Return the name of the game. Names are fully lowercase. - Implementations of the class should replace this with a human readable name of the game. + Return the player membership limit of this game. Returns ------- - str - Name of the game. + Union[int, None] + The player membership limit. """ - return "game" + return super().get_player_limit() - def get_player_concurrent_limit(self): + def get_player_concurrent_limit(self) -> Union[int, None]: """ Return the concurrent player membership limit of this game. Returns ------- - int or None + Union[int, None] The concurrent player membership limit. """ - return self._playergroup.get_player_concurrent_limit() + return super().get_player_concurrent_limit() - def get_players(self, cond=None): + def get_players( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ Return (a shallow copy of) the set of players of this game that satisfy a condition if given. Parameters ---------- - cond : types.LambdaType: ClientManager.Client -> bool, optional + cond : Callable[[ClientManager.Client, ], bool], optional Condition that all players returned satisfy. Defaults to None (no checked conditions). Returns ------- - set of ClientManager.Client + Set[ClientManager.Client] The (filtered) players of this game. """ - return self._playergroup.get_players(cond=cond) + return super().get_players(cond=cond) - def is_player(self, user): + def is_player(self, user: ClientManager.Client) -> bool: """ Decide if a user is a player of the game. @@ -376,12 +276,13 @@ def is_player(self, user): """ - return self._playergroup.is_player(user) + return super().is_player(user) - def add_player(self, user): + def add_player(self, user: ClientManager.Client): """ - Make a user a player of the game. By default this player will not be a leader. It will - also subscribe the game to the player so it can listen to its updates. + Make a user a player of the game. By default this player will not be a leader, unless the + game has no leaders and it requires a leader. + It will also subscribe the game to the player so it can listen to its updates. Parameters ---------- @@ -394,41 +295,25 @@ def add_player(self, user): If the game was scheduled for deletion and thus does not accept any mutator public method calls. GameError.UserHasNoCharacterError - If the user has no character but the game requires that all players have characters. + If the user has no character but the game requires that all players have participant + characters. GameError.UserNotInvitedError If the game requires players be invited to be added and the user is not invited. GameError.UserAlreadyPlayerError If the user to add is already a user of the game. GameError.UserHitGameConcurrentLimitError - If the player has reached any of the games it belongs to managed by this game's - manager concurrent player membership limit, or by virtue of joining this game they - will violate this game's concurrent player membership limit. + If the player has reached the concurrent player membership of any of the games managed + by the manager of this game, or by virtue of joining this game they + would violate this game's concurrent player membership limit. GameError.GameIsFullError If the game reached its player limit. """ - if self.is_unmanaged(): - raise GameError.GameIsUnmanagedError - # If the game demands the player has a character, check that too - if self._require_character and not user.has_character(): - raise GameError.UserHasNoCharacterError - - try: - self._playergroup.add_player(user) - except PlayerGroupError.UserNotInvitedError: - raise GameError.UserNotInvitedError - except PlayerGroupError.UserAlreadyPlayerError: - raise GameError.UserAlreadyPlayerError - except PlayerGroupError.UserHitGroupConcurrentLimitError: - raise GameError.UserHitGameConcurrentLimitError - except PlayerGroupError.GroupIsFullError: - raise GameError.GameIsFullError - - self.listener.subscribe(user) - self._manager._check_structure() + self.unchecked_add_player(user) + self.manager._check_structure() - def remove_player(self, user): + def remove_player(self, user: ClientManager.Client): """ Make a user be no longer a player of this game. If they were part of a team managed by this game, they will also be removed from said team. It will also unsubscribe the game @@ -452,51 +337,45 @@ def remove_player(self, user): """ - if self.is_unmanaged(): - raise GameError.GameIsUnmanagedError - - if not self._playergroup.is_player(user): - raise GameError.UserNotPlayerError - - user_teams = self.get_teams_of_user(user) - for team in user_teams: - team.remove_player(user) - - try: - self._playergroup.remove_player(user) - except PlayerGroupError.UserNotPlayerError: - # Should not have made it here as we already asserted the user is a player - raise RuntimeError(self) + self.unchecked_remove_player(user) + self.manager._check_structure() - self.listener.unsubscribe(user) + def requires_players(self) -> bool: + """ + Return whether the game requires players at all times. - # Detect if the internal player group was scheduled for deletion - if self._playergroup.is_unmanaged(): - self.destroy() + Returns + ------- + bool + Whether the game requires players at all times. + """ - self._manager._check_structure() + return super().requires_players() - def get_invitations(self, cond=None): + def get_invitations( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ Return (a shallow copy of) the set of invited users of this game that satisfy a condition if given. Parameters ---------- - cond : types.LambdaType: ClientManager.Client -> bool, optional + cond : Callable[[ClientManager.Client, ], bool], optional Condition that all invited users returned satisfy. Defaults to None (no checked conditions). Returns ------- - set of ClientManager.Client + Set[ClientManager.Client] The (filtered) invited users of this game. """ - return self._playergroup.get_invitations(cond=cond) + return super().get_invitations(cond=cond) - def is_invited(self, user): + def is_invited(self, user: ClientManager.Client) -> bool: """ Decide if a user is invited to the game. @@ -518,11 +397,11 @@ def is_invited(self, user): """ try: - return self._playergroup.is_invited(user) + return super().is_invited(user) except PlayerGroupError.UserAlreadyPlayerError: raise GameError.UserAlreadyPlayerError - def add_invitation(self, user): + def add_invitation(self, user: ClientManager.Client): """ Mark a user as invited to this game. @@ -545,11 +424,38 @@ def add_invitation(self, user): """ - if self.is_unmanaged(): - raise GameError.GameIsUnmanagedError + self.unchecked_add_invitation(user) + self.manager._check_structure() + + def unchecked_add_invitation(self, user: ClientManager.Client): + """ + Mark a user as invited to this game. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to invite to the game. + + Raises + ------ + GameError.GameIsUnmanagedError + If the game was scheduled for deletion and thus does not accept any mutator + public method calls. + GameError.GameDoesNotTakeInvitationsError + If the game does not require users be invited to the game. + GameError.UserAlreadyInvitedError + If the player to invite is already invited to the game. + GameError.UserAlreadyPlayerError + If the player to invite is already a player of the game. + + """ try: - self._playergroup.add_invitation(user) + super().unchecked_add_invitation(user) + except PlayerGroupError.GroupIsUnmanagedError: + raise GameError.GameIsUnmanagedError except PlayerGroupError.GroupDoesNotTakeInvitationsError: raise GameError.GameDoesNotTakeInvitationsError except PlayerGroupError.UserAlreadyInvitedError: @@ -557,9 +463,7 @@ def add_invitation(self, user): except PlayerGroupError.UserAlreadyPlayerError: raise GameError.UserAlreadyPlayerError - self._manager._check_structure() - - def remove_invitation(self, user): + def remove_invitation(self, user: ClientManager.Client): """ Mark a user as no longer invited to this game (uninvite). @@ -580,18 +484,41 @@ def remove_invitation(self, user): """ - if self.is_unmanaged(): - raise GameError.GameIsUnmanagedError + self.unchecked_remove_invitation(user) + self.manager._check_structure() + + def unchecked_remove_invitation(self, user: ClientManager.Client): + """ + Mark a user as no longer invited to this game (uninvite). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + GameError.GameIsUnmanagedError + If the game was scheduled for deletion and thus does not accept any mutator + public method calls. + GameError.GameDoesNotTakeInvitationsError + If the game does not require users be invited to the game. + GameError.UserNotInvitedError + If the user to uninvite is already not invited to this game. + + """ try: - self._playergroup.remove_invitation(user) + super().unchecked_remove_invitation(user) + except PlayerGroupError.GroupIsUnmanagedError: + raise GameError.GameIsUnmanagedError except PlayerGroupError.GroupDoesNotTakeInvitationsError: raise GameError.GameDoesNotTakeInvitationsError except PlayerGroupError.UserNotInvitedError: raise GameError.UserNotInvitedError - self._manager._check_structure() - def requires_invitations(self): """ Return True if the game requires players be invited before being allowed to join @@ -604,48 +531,55 @@ def requires_invitations(self): the game, False otherwise. """ - return self._playergroup.requires_invitations() + return super().requires_invitations() - def get_leaders(self, cond=None): + def get_leaders( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ Return (a shallow copy of) the set of leaders of this game that satisfy a condition if given. Parameters ---------- - cond : types.LambdaType: ClientManager.Client -> bool, optional + cond : Callable[[ClientManager.Client, ], bool], optional Condition that all leaders returned satisfy. Defaults to None (no checked conditions). Returns ------- - set of ClientManager.Client + Set[ClientManager.Client] The (filtered) leaders of this game. """ - return self._playergroup.get_leaders(cond=cond) + return super().get_leaders(cond=cond) - def get_regulars(self, cond=None): + def get_regulars( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ Return (a shallow copy of) the set of players of this game that are regulars and satisfy a condition if given. Parameters ---------- - cond : types.LambdaType: ClientManager.Client -> bool, optional + cond : Callable[[ClientManager.Client, ], bool], optional Condition that all regulars returned satisfy. Defaults to None (no checked conditions). Returns ------- - set of ClientManager.Client + Set[ClientManager.Client] The (filtered) regulars of this game. """ - return self._playergroup.get_regulars(cond=cond) - def is_leader(self, user): + return super().get_regulars(cond=cond) + + def is_leader(self, user: ClientManager.Client) -> bool: """ Decide if a user is a leader of the game. @@ -667,11 +601,11 @@ def is_leader(self, user): """ try: - return self._playergroup.is_leader(user) + return super().is_leader(user) except PlayerGroupError.UserNotPlayerError: raise GameError.UserNotPlayerError - def add_leader(self, user): + def add_leader(self, user: ClientManager.Client): """ Set a user as leader of this game (promote to leader). @@ -692,19 +626,42 @@ def add_leader(self, user): """ - if self.is_unmanaged(): - raise GameError.GameIsUnmanagedError + self.unchecked_add_leader(user) + self.manager._check_structure() + + def unchecked_add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this game (promote to leader). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + GameError.GameIsUnmanagedError + If the game was scheduled for deletion and thus does not accept any mutator + public method calls. + GameError.UserNotPlayerError + If the player to promote is not a player of this game. + GameError.UserAlreadyLeaderError + If the player to promote is already a leader of this game. + + """ try: - self._playergroup.add_leader(user) + super().unchecked_add_leader(user) + except PlayerGroupError.GroupIsUnmanagedError: + raise GameError.GameIsUnmanagedError except PlayerGroupError.UserNotPlayerError: raise GameError.UserNotPlayerError except PlayerGroupError.UserAlreadyLeaderError: raise GameError.UserAlreadyLeaderError - self._manager._check_structure() - - def remove_leader(self, user): + def remove_leader(self, user: ClientManager.Client): """ Make a user no longer leader of this game (demote). @@ -725,34 +682,479 @@ def remove_leader(self, user): """ - if self.is_unmanaged(): - raise GameError.GameIsUnmanagedError - - try: - self._playergroup.remove_leader(user) - except PlayerGroupError.UserNotPlayerError: - raise GameError.UserNotPlayerError - except PlayerGroupError.UserNotLeaderError: - raise GameError.UserNotLeaderError - - self._manager._check_structure() + self.unchecked_remove_leader(user) + self.manager._check_structure() - def new_timer(self, timer_type=None, start_value=None, tick_rate=1, - min_value=None, max_value=None, auto_restart=False, auto_destroy=True): + def unchecked_remove_leader(self, user: ClientManager.Client): """ - Create a new timer with given parameters managed by this game. + Make a user no longer leader of this game (demote). + + This method does not assert structural integrity. Parameters ---------- - timer_type : TimerManager.Timer, optional - Class of timer that will be produced. Defaults to None (and converted to - TimerManager.Timer) - start_value : float, optional + user : ClientManager.Client + User to demote. + + Raises + ------ + GameError.GameIsUnmanagedError + If the game was scheduled for deletion and thus does not accept any mutator + public method calls. + GameError.UserNotPlayerError + If the player to demote is not a player of this game. + GameError.UserNotLeaderError + If the player to demote is already not a leader of this game. + + """ + + try: + super().unchecked_remove_leader(user) + except PlayerGroupError.GroupIsUnmanagedError: + raise GameError.GameIsUnmanagedError + except PlayerGroupError.UserNotPlayerError: + raise GameError.UserNotPlayerError + except PlayerGroupError.UserNotLeaderError: + raise GameError.UserNotLeaderError + + def has_ever_had_players(self) -> bool: + """ + Return True if a player has ever been added to this game, False otherwise. + + Returns + ------- + bool + True if the game has ever had a player added, False otherwise. + + """ + + return super().has_ever_had_players() + + def requires_leaders(self) -> bool: + """ + Return whether the game requires leaders at all times. + + Returns + ------- + bool + Whether the game requires leaders at all times. + """ + + return super().requires_leaders() + + def is_unmanaged(self): + """ + Return True if this game is unmanaged, False otherwise. + + Returns + ------- + bool + True if unmanaged, False otherwise. + + """ + + return super().is_unmanaged() + + def destroy(self): + """ + Mark this game as destroyed and notify its manager so that it is deleted. + If the game is already destroyed, this function does nothing. + A game marked for destruction will delete all of its timers, teams, remove all its + players and unsubscribe it from updates of its former players. + + This method is reentrant (it will do nothing though). + + Returns + ------- + None. + + """ + + self.unchecked_destroy() + self.manager._check_structure() + self._check_structure() # Manager will not check this otherwise. + + +class _Game(_GameTrivialInherited): + """ + A mutable data type for games. + + Games are groups of users (called players) with an ID, that may also manage some timers + and teams. + + Some players of the game (possibly none) may become leaders. A player that is not a leader + is called regular. Each game may have a player limit (beyond which no new players may be added), + may require that it never loses all its players as soon as it gets its first one (or else it + is automatically deleted) and may require that if it has at least one player, then that there + is at least one leader (or else one is automatically chosen between all players). Each of these + games may also impose a concurrent player membership limit, so that every user that is a player + of it is at most of that many games managed by this game's manager. Each game may also + require all its players have participant characters when trying to join the game, as well as + remove any player that switches to a non-character. + + Each of the timers a game manages are timer_manager.Timers. + + For each managed team, its players must also be players of this game. + + Once a game is scheduled for deletion, its manager will no longer recognize it as a game + it is managing (it will unmanage it), so no further mutator public method calls would be + allowed on the game. + + Each game also has a standard listener. By default the game subscribes to all its players' + updates. + + Attributes + ---------- + server : TsuserverDR + Server the game belongs to. + manager : GameManager + Manager for this game. + listener : Listener + Standard listener of the game. + + Callback Methods + ---------------- + _on_client_inbound_ms_check + Method to perform once a player of the game wants to send an IC message. + _on_client_inbound_ms_final + Method to perform once a player of the game sends an IC message. + _on_client_change_character + Method to perform once a player of the game has changed character. + _on_client_destroyed + Method to perform once a player of the game is destroyed. + + """ + + # (Private) Attributes + # -------------------- + # _require_participant_characters : bool + # If False, players without a participant character will not be allowed to join the game, and + # players that switch to something other than a participant character will be automatically + # removed from the game. If False, no such checks are made. + # _team_manager : PlayerGroupManager + # Internal manager that handles the teams of the game. + # _timer_manager: TimerManager + # Internal manager that handles the timers of the game. + + # Invariants + # ---------- + # 1. All players part of a team managed by this game are players of the game. + # 2. For each player of the game, the game is subscribed to it. + # 3. If the game requires its players have participant characters, all its players do have + # participant characters. + # 4. Each internal structure satisfies its invariants. + # 5. The invariants from the parent class _PlayerGroup are satisfied. + + def __init__( + self, + server: TsuserverDR, + manager: GameManager, + game_id: str, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + ): + """ + Create a new game. A game should not be fully initialized anywhere else other than + some manager code, as otherwise the manager will not recognize the game. + + Parameters + ---------- + server : TsuserverDR + Server the game belongs to. + manager : GameManager + Manager for this game. + game_id : str + Identifier of the game. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the game supports. If None, it + indicates the game has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of games managed by `manager` that any + player of this game may belong to, including this game. If None, it indicates + that this game does not care about how many other games managed by `manager` each + of its players belongs to. Defaults to None. + require_invitation : bool, optional + If True, players can only be added to the game if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the game has no players left, the game will + automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + require_leaders : bool, optional + If True, if at any point the game has no leaders left, the game will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + game, and players that switch to something other than a participant character + will be automatically removed from the game. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the game supports. If None, it + indicates the game has no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the game supports. If None, it + indicates the game has no timer limit. Defaults to None. + + """ + + super().__init__( + server, + manager, + game_id, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + + self._team_manager = PlayerGroupManager( + server, + managee_limit=team_limit, + default_managee_type=_Team + ) + self._timer_manager = TimerManager( + server, + timer_limit=timer_limit + ) + self._require_participant_characters = require_participant_character + + self.publisher = Publisher(self) + # Implementation detail: the callbacks of the internal objects of the game are (to be) + # ignored. + self.listener = Listener(self, { + 'client_inbound_ms_final': self._on_client_inbound_ms_final, + 'client_inbound_ms_check': self._on_client_inbound_ms_check, + 'client_change_character': self._on_client_change_character, + 'client_destroyed': self._on_client_destroyed, + }) + + def get_type_name(self) -> str: + """ + Return the type name of the game. Names are fully lowercase. + Implementations of the class should replace this with a human readable name of the game. + + Returns + ------- + str + Type name of the game. + + """ + + return "game" + + def unchecked_add_player(self, user: ClientManager.Client): + """ + Make a user a player of the game. By default this player will not be a leader, unless the + game has no leaders and the game requires a leader. + It will also subscribe the game to the player so it can listen to its updates. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to add to the game. + + Raises + ------ + GameError.GameIsUnmanagedError + If the game was scheduled for deletion and thus does not accept any mutator + public method calls. + GameError.UserHasNoCharacterError + If the user has no character but the game requires that all players have participant + characters. + GameError.UserNotInvitedError + If the game requires players be invited to be added and the user is not invited. + GameError.UserAlreadyPlayerError + If the user to add is already a user of the game. + GameError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the games managed + by the manager of this game, or by virtue of joining this game they + would violate this game's concurrent player membership limit. + GameError.GameIsFullError + If the game reached its player limit. + + """ + + if self.is_unmanaged(): + raise GameError.GameIsUnmanagedError + if self._require_participant_characters and not user.has_participant_character(): + raise GameError.UserHasNoCharacterError + + try: + super().unchecked_add_player(user) + except PlayerGroupError.GroupIsUnmanagedError: + raise RuntimeError(self, user) + except PlayerGroupError.UserNotInvitedError: + raise GameError.UserNotInvitedError + except PlayerGroupError.UserAlreadyPlayerError: + raise GameError.UserAlreadyPlayerError + except PlayerGroupError.UserHitGroupConcurrentLimitError: + raise GameError.UserHitGameConcurrentLimitError + except PlayerGroupError.GroupIsFullError: + raise GameError.GameIsFullError + + self.listener.subscribe(user) + + def unchecked_remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this game. If they were part of a team managed by + this game, they will also be removed from said team. It will also unsubscribe the game + from the player so it will no longer listen to its updates. + + If the game required that there it always had players and by calling this method the + game had no more players, the game will automatically be scheduled for deletion. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + GameError.GameIsUnmanagedError + If the game was scheduled for deletion and thus does not accept any mutator + public method calls. + GameError.UserNotPlayerError + If the user to remove is already not a player of this game. + + """ + + if self.is_unmanaged(): + raise GameError.GameIsUnmanagedError + if not self.is_player(user): + raise GameError.UserNotPlayerError + + user_teams = self.get_teams_of_user(user) + for team in user_teams: + team.remove_player(user) + + try: + super().unchecked_remove_player(user) + except PlayerGroupError.GroupIsUnmanagedError: + # Should not have made it here as we already asserted the game is not unmmanaged + raise RuntimeError(self, user) + except PlayerGroupError.UserNotPlayerError: + # Should not have made it here as we already asserted the user is a player + raise RuntimeError(self, user) + + self.listener.unsubscribe(user) + + def requires_participant_characters(self) -> bool: + """ + Return whether the game requires players have a participant character at all times. + + Returns + ------- + bool + Whether the game requires players have a participant character at all times. + """ + + return self._require_participant_characters + + def new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this game with given parameters. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the game will automatically delete the timer once it is terminated by it + ticking out or manual termination. If False, no such automatic deletion will take place. + Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + GameError.GameIsUnmanagedError + If the game was scheduled for deletion and thus does not accept any mutator + public method calls. + GameError.GameTooManyTimersError + If the game is already managing its maximum number of timers. + + """ + + timer = self.unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + self.manager._check_structure() + return timer + + def unchecked_new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this game with given parameters. + + This method does not assert structural integrity. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional Number of seconds the apparent timer the timer will initially have. Defaults to None (will use the default from `timer_type`). tick_rate : float, optional - Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to - None (will use the default from `timer_type`). + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. min_value : float, optional Minimum value the apparent timer may take. If the timer ticks below this, it will end automatically. It must be a non-negative number. Defaults to None (will use the @@ -773,7 +1175,7 @@ def new_timer(self, timer_type=None, start_value=None, tick_rate=1, Returns ------- - TimerManager.Timer + Timer The created timer. Raises @@ -790,26 +1192,27 @@ def new_timer(self, timer_type=None, start_value=None, tick_rate=1, raise GameError.GameIsUnmanagedError try: - timer = self._timer_manager.new_timer(timer_type=timer_type, - start_value=start_value, - tick_rate=tick_rate, - min_value=min_value, - max_value=max_value, - auto_restart=auto_restart, - auto_destroy=auto_destroy) + timer = self._timer_manager.new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy + ) except TimerError.ManagerTooManyTimersError: raise GameError.GameTooManyTimersError - self._manager._check_structure() return timer - def delete_timer(self, timer): + def delete_timer(self, timer: Timer) -> str: """ Delete a timer managed by this game, terminating it first if needed. Parameters ---------- - timer : TimerManager.Timer + timer : Timer The timer to delete. Returns @@ -819,6 +1222,39 @@ def delete_timer(self, timer): Raises ------ + GameError.GameIsUnmanagedError + If the game was scheduled for deletion and thus does not accept any mutator + public method calls. + GameError.GameDoesNotManageTimerError + If the game does not manage the target timer. + + """ + + timer_id = self.unchecked_delete_timer(timer) + self.manager._check_structure() + return timer_id + + def unchecked_delete_timer(self, timer: Timer) -> str: + """ + Delete a timer managed by this game, terminating it first if needed. + + This method does not assert structural integrity. + + Parameters + ---------- + timer : Timer + The timer to delete. + + Returns + ------- + str + The ID of the timer that was deleted. + + Raises + ------ + GameError.GameIsUnmanagedError + If the game was scheduled for deletion and thus does not accept any mutator + public method calls. GameError.GameDoesNotManageTimerError If the game does not manage the target timer. @@ -832,23 +1268,22 @@ def delete_timer(self, timer): except TimerError.ManagerDoesNotManageTimerError: raise GameError.GameDoesNotManageTimerError - self._manager._check_structure() return timer_id - def get_timers(self): + def get_timers(self) -> Set[Timer]: """ Return (a shallow copy of) the timers this game manages. Returns ------- - set of TimerManager.Timer + Set[Timer] Timers this game manages. """ return self._timer_manager.get_timers() - def get_timer_by_id(self, timer_id): + def get_timer_by_id(self, timer_id: str) -> Timer: """ If `timer_tag` is the ID of a timer managed by this game, return that timer. @@ -859,7 +1294,7 @@ def get_timer_by_id(self, timer_id): Returns ------- - TimerManager.Timer + Timer The timer whose ID matches the given ID. Raises @@ -874,21 +1309,41 @@ def get_timer_by_id(self, timer_id): except TimerError.ManagerInvalidTimerIDError: raise GameError.GameInvalidTimerIDError - def get_timer_ids(self): + def get_timer_limit(self) -> Union[int, None]: + """ + Return the timer limit of this game. + + Returns + ------- + Union[int, None] + Timer limit. + + """ + + return self._timer_manager.get_timer_limit() + + def get_timer_ids(self) -> Set[str]: """ Return (a shallow copy of) the IDs of all timers managed by this game. Returns ------- - set of str - The ID of the timer that matches the given tag. + Set[str] + The IDs of all managed timers. """ return self._timer_manager.get_timer_ids() - def new_team(self, team_type=None, creator=None, player_limit=None, - require_invitations=False, require_players=True, require_leaders=True): + def new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: """ Create a new team managed by this game. @@ -931,30 +1386,127 @@ def new_team(self, team_type=None, creator=None, player_limit=None, """ - if self.is_unmanaged(): - raise GameError.GameIsUnmanagedError - - if team_type is None: - team_type = _Team - - try: - team = self._team_manager.new_group(playergroup_type=team_type, creator=creator, - player_limit=player_limit, - player_concurrent_limit=1, - require_invitations=require_invitations, - require_players=require_players, - require_leaders=require_leaders) - except PlayerGroupError.ManagerTooManyGroupsError: - raise GameError.GameTooManyTeamsError - except PlayerGroupError.UserHitGroupConcurrentLimitError: - raise GameError.UserInAnotherTeamError - team._game = self._playergroup - return team + team = self.unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + self.manager._check_structure() + return team + + def unchecked_new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this game. + + This method does not assert structural integrity. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this game. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + + Returns + ------- + _Team + The created team. + + Raises + ------ + GameError.GameIsUnmanagedError + If the game was scheduled for deletion and thus does not accept any mutator + public method calls. + GameError.GameTooManyTeamsError + If the game is already managing its maximum number of teams. + GameError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this game. + + """ + + if self.is_unmanaged(): + raise GameError.GameIsUnmanagedError + + if team_type is None: + team_type = _Team + + try: + team = self._team_manager.new_managee( + managee_type=team_type, + creator=creator, + player_limit=player_limit, + player_concurrent_limit=1, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + game=self, + ) + except PlayerGroupError.ManagerTooManyGroupsError: + raise GameError.GameTooManyTeamsError + except PlayerGroupError.UserHitGroupConcurrentLimitError: + raise GameError.UserInAnotherTeamError + return team + + def delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this game. + + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + GameError.GameIsUnmanagedError + If the game was scheduled for deletion and thus does not accept any mutator + public method calls. + GameError.GameDoesNotManageTeamError + If the game does not manage the target team. + + """ + + team_id, players = self.unchecked_delete_team(team) + self.manager._check_structure() + return team_id, players - def delete_team(self, team): + def unchecked_delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: """ Delete a team managed by this game. + This method does not assert structural integrity. + Parameters ---------- team : _Team @@ -962,7 +1514,7 @@ def delete_team(self, team): Returns ------- - (str, set of ClientManager.Client) + Tuple[str, Set[ClientManager.Client]] The ID and players of the team that was deleted. Raises @@ -979,11 +1531,11 @@ def delete_team(self, team): raise GameError.GameIsUnmanagedError try: - self._team_manager.delete_group(team) + return self._team_manager.delete_managee(team) except PlayerGroupError.ManagerDoesNotManageGroupError: raise GameError.GameDoesNotManageTeamError - def manages_team(self, team): + def manages_team(self, team: _Team) -> bool: """ Return True if the team is managed by this game, False otherwise. @@ -999,24 +1551,24 @@ def manages_team(self, team): """ - return self._team_manager.manages_group(team) + return self._team_manager.manages_managee(team) - def get_teams(self): + def get_teams(self) -> Set[_Team]: """ Return (a shallow copy of) the teams this game manages. Returns ------- - set of _Team + Set[_Team] Teams this game manages. """ - return self._team_manager.get_groups().copy() + return self._team_manager.get_managees() - def get_team_by_id(self, team_id): + def get_team_by_id(self, team_id: str) -> _Team: """ - If `team_id` is the ID of a team managed by this manager, return the team. + If `team_id` is the ID of a team managed by this game, return the team. Parameters ---------- @@ -1036,40 +1588,40 @@ def get_team_by_id(self, team_id): """ try: - return self._team_manager.get_group_by_id(team_id) + return self._team_manager.get_managee_by_id(team_id) except PlayerGroupError.ManagerInvalidGroupIDError: raise GameError.GameInvalidTeamIDError - def get_team_limit(self): + def get_team_limit(self) -> Union[int, None]: """ Return the team limit of this game. Returns ------- - int + Union[int, None] Team limit. """ - return self._team_manager.get_group_limit() + return self._team_manager.get_managee_limit() - def get_team_ids(self): + def get_team_ids(self) -> Set[str]: """ Return (a shallow copy of) the IDs of all teams managed by this game. Returns ------- - set of str + Set[str] The IDs of all managed teams. """ - return self._team_manager.get_group_ids().copy() + return self._team_manager.get_managee_ids() - def get_teams_of_user(self, user): + def get_teams_of_user(self, user: ClientManager.Client) -> Set[_Team]: """ - Return (a shallow copy of) the teams managed by this game user `user` is a - player of. If the user is part of no such team, an empty set is returned. + Return (a shallow copy of) the teams managed by this game user `user` is a player of. + If the user is part of no such team, an empty set is returned. Parameters ---------- @@ -1078,35 +1630,34 @@ def get_teams_of_user(self, user): Returns ------- - set of _Team + Set[_Team] Teams the player belongs to. """ - return self._team_manager.get_groups_of_user(user).copy() + return self._team_manager.get_managees_of_user(user) - def get_users_in_team(self): + def get_users_in_some_team(self): """ - Return (a shallow copy of) all the users that are part of some team managed by - this manager. + Return (a shallow copy of) all the users that are part of some team managed by this game. Returns ------- - set of ClientManager.Client + Set[ClientManager.Client] Users in some managed team. """ - return self._team_manager.get_users_in_groups().copy() + return self._team_manager.get_users_in_some_managee() - def get_available_team_id(self): + def get_available_team_id(self) -> str: """ Get a team ID that no other team managed by this team has. Returns ------- str - A unique player group ID. + A unique team ID. Raises ------ @@ -1116,24 +1667,11 @@ def get_available_team_id(self): """ try: - return self._team_manager.get_available_group_id() + return self._team_manager.get_available_managee_id() except PlayerGroupError.ManagerTooManyGroupsError: - return GameError.GameTooManyTeamsError - - def is_unmanaged(self): - """ - Return True if this game is unmanaged, False otherwise. - - Returns - ------- - bool - True if unmanaged, False otherwise. - - """ - - return self._playergroup.is_unmanaged() + raise GameError.GameTooManyTeamsError - def destroy(self): + def unchecked_destroy(self): """ Mark this game as destroyed and notify its manager so that it is deleted. If the game is already destroyed, this function does nothing. @@ -1148,39 +1686,22 @@ def destroy(self): """ - if self._unmanaged: - return - self._unmanaged = True - for timer in self._timer_manager.get_timers(): self._timer_manager.delete_timer(timer) - for team in self._team_manager.get_groups(): - team.destroy() - - # Players (and subscriptions to them) are handled in the manager's delete code. - try: - self._manager.delete_game(self) - except GameError.ManagerDoesNotManageGameError: - # Should only happen if .destroy() was called from a delete_game - # At this point it is safe not to call delete_game - pass - - self._check_structure() - - def has_ever_had_players(self): - """ - Return True if a player has ever been added to this game, False otherwise. - - Returns - ------- - bool - True if the game has ever had a player added, False otherwise. + for team in self._team_manager.get_managees(): + team.unchecked_destroy() - """ + players = self.get_players() + super().unchecked_destroy() - return self._playergroup.has_ever_had_players() + for player in players: + self.listener.unsubscribe(player) - def _on_client_inbound_ms_check(self, player, contents=None): + def _on_client_inbound_ms_check( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): """ Default callback for game player signaling it wants to check if sending an IC message is appropriate. The IC arguments can be passed by reference, so this also serves as an @@ -1193,7 +1714,7 @@ def _on_client_inbound_ms_check(self, player, contents=None): ---------- player : ClientManager.Client Player that wants to send the IC message. - contents : dict of str to Any + contents : Dict[str, Any], optional Arguments of the IC message as indicated in AOProtocol. Returns @@ -1204,7 +1725,11 @@ def _on_client_inbound_ms_check(self, player, contents=None): # print('Player', player, 'wants to check sent', contents) - def _on_client_inbound_ms_final(self, player, contents=None): + def _on_client_inbound_ms_final( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): """ Default callback for game player signaling it has sent an IC message. This callback is executed after the server is done making all modifications to the MS packet @@ -1216,7 +1741,7 @@ def _on_client_inbound_ms_final(self, player, contents=None): ---------- player : ClientManager.Client Player that signaled it has sent an IC message. - contents : dict of str to Any + contents : Dict[str, Any], optional Arguments of the IC message as indicated in AOProtocol. Returns @@ -1227,22 +1752,39 @@ def _on_client_inbound_ms_final(self, player, contents=None): # print('Player', player, 'sent', contents) - def _on_client_change_character(self, player, old_char_id=None, new_char_id=None): + def _on_client_change_character( + self, + player: ClientManager.Client, + old_char_id: int = -1, + old_char_name: str = '', + new_char_id: int = -1, + new_char_name: str = '', + ): """ Default callback for game player signaling it has changed character. - By default it only checks if the player is now no longer having a character. If that is - the case and the game requires all players have characters, the player is automatically - removed. + By default it only checks if the player is now no longer having a participant character. + If that is the case and the game requires all players have participant characters, the + player is automatically removed. + + Note that it may not necessarily be the case that the following hold: + 1. `old_char_name == player.hub.character_manager.get_character_name(old_char_id)`. + 2. `new_char_name == player.hub.character_manager.get_character_name(new_char_id)`. + This can occur for example if the character list changes, which prompts the player to + change character. Parameters ---------- player : ClientManager.Client Player that signaled it has changed character. old_char_id : int, optional - Previous character ID. The default is None. + Previous character ID. The default is -1. + old_char_name : str, optional + Previous character name. The default is the empty string. new_char_id : int, optional - New character ID. The default is None. + New character ID. The default is -1. + new_char_name : int, optional + New character name. The default is the empty string. Returns ------- @@ -1251,12 +1793,10 @@ def _on_client_change_character(self, player, old_char_id=None, new_char_id=None """ # print('Player', player, 'changed character from', old_char_id, 'to', new_char_id) - if self._require_character and not player.has_character(): + if self._require_participant_characters and not player.has_participant_character(): self.remove_player(player) - self._check_structure() - - def _on_client_destroyed(self, player): + def _on_client_destroyed(self, player: ClientManager.Client): """ Default callback for game player signaling it was destroyed, for example, as a result of a disconnection. @@ -1282,8 +1822,6 @@ def _on_client_destroyed(self, player): return self.remove_player(player) - self._check_structure() - def _check_structure(self): """ Assert that all invariants specified in the class description are maintained. @@ -1296,41 +1834,32 @@ def _check_structure(self): """ # 1. - team_players = self._team_manager.get_users_in_groups() - game_players = self._playergroup.get_players() + team_players = self._team_manager.get_users_in_some_managee() + game_players = self.get_players() team_not_in_game = {player for player in team_players if player not in game_players} - err = (f'For game {self}, expected that every player in the set {team_players} of all ' - f'players in a team managed by the game is in the set {game_players} of players ' - f'of the game, found the following players that did not satisfy this: ' - f'{team_not_in_game}') - assert team_players.issubset(game_players), err + assert team_players.issubset(game_players), ( + f'For game {self}, expected that every player in the set {team_players} of all players ' + f'in a team managed by the game is in the set {game_players} of players of the game, ' + f'found the following players that did not satisfy this: {team_not_in_game}' + ) # 2. - internal_unmanaged = self._playergroup.is_unmanaged() - self_unmanaged = self.is_unmanaged() - - err = (f'For game {self}, expected that both itself and its internal player group ' - f'were either both unmanaged or both not unmanaged, found that itself was ' - f'{"" if self_unmanaged else "not"}unmanaged but its playergroup was ' - f'{"" if internal_unmanaged else "not"}unmanaged.') - assert internal_unmanaged == self_unmanaged, err - - # 3. listener_parents = {obj.get_parent() for obj in self.listener.get_subscriptions()} for player in self.get_players(): - err = (f'For game {self}, expected that its player {player} was among its ' - f'subscriptions {listener_parents} found it was not.') - assert player in listener_parents, err + assert player in listener_parents, ( + f'For game {self}, expected that its player {player} was among its ' + f'subscriptions {listener_parents} found it was not.' + ) - # 4. - if self._require_character: + # 3. + if self._require_participant_characters: for player in self.get_players(): - err = (f'For game with areas {self} that expected all its players had ' - f'characters, found player {player} did not have a character.') - assert player.has_character(), err + assert player.has_participant_character(), ( + f'For game with areas {self} that expected all its players had participant ' + f'characters, found player {player} did not have a participant character.' + ) # 4. - self._playergroup._check_structure() self._timer_manager._check_structure() self._team_manager._check_structure() @@ -1361,54 +1890,38 @@ def __repr__(self): """ - return (f'_Game(server, {self._manager.get_id()}, "{self.get_id()}", ' - f'player_limit={self._playergroup._player_limit}, ' + return (f'_Game(server, {self.get_id()}, "{self.get_id()}", ' + f'player_limit={self.get_player_limit()}, ' f'player_concurrent_limit={self.get_player_concurrent_limit()}, ' - f'require_players={self._playergroup._require_players}, ' - f'require_invitations={self._playergroup._require_invitations}, ' - f'require_leaders={self._playergroup._require_leaders}, ' - f'require_character={self._require_character}, ' - f'team_limit={self._team_manager.get_group_limit()}, ' - f'timer_limit={self._timer_manager.get_timer_limit()} || ' + f'require_players={self.requires_players()}, ' + f'require_invitations={self.requires_invitations()}, ' + f'require_leaders={self.requires_leaders()}, ' + f'require_participant_character={self.requires_participant_characters()}, ' + f'team_limit={self.get_team_limit()}, ' + f'timer_limit={self.get_timer_limit()}, ' + f'|| ' f'players={self.get_players()}, ' f'invitations={self.get_invitations()}, ' f'leaders={self.get_leaders()}, ' f'timers={self.get_timers()}, ' - f'teams={self.get_teams()})') + f'teams={self.get_teams()}), ' + f'unmanaged={self.is_unmanaged()}), ' + f')') -class GameManager: - """ - A mutable data type for a manager for games. - - Each game is managed by a game manager. Only this manager is allowed to execute any public - methods on them. Each manager may also have a game limit (beyond which it will not manage any - more groups). - - Contains methods for creating and deleting games, as well as some observer methods. +class _GameManagerTrivialInherited(PlayerGroupManager): + """ + This class should not be instantiated. """ - # (Private) Attributes - # -------------------- - # _server : TsuserverDR - # Server the game manager belongs to. - # _default_game_type : _Game or functools.partial - # The type of game this game manager will create by default when ordered to create a new - # one. - # _playergroup_manager : PlayerGroupManager - # Internal player group manager that handles all game functions. - # _id_to_game : dict of str to _Game - # Mapping of game IDs to games that this manager manages. - - # Invariants - # ---------- - # 1. `self._playergroup_manager.get_group_ids()` and `self._id_to_game.keys()` are equal. - # 2. For each pair `(game_id, game)` in `self._id_to_game.items()`: - # a. `game.get_id() == game_id`. - def __init__(self, server, game_limit=None, default_game_type=None, - available_id_producer=None): + def __init__( + self, + server: TsuserverDR, + managee_limit: Union[int, None] = None, + default_managee_type: Type[_Game] = None, + ): """ Create a game manager object. @@ -1416,48 +1929,66 @@ def __init__(self, server, game_limit=None, default_game_type=None, ---------- server : TsuserverDR The server this game manager belongs to. - game_limit : int, optional + managee_limit : int, optional The maximum number of games this manager can handle. Defaults to None (no limit). - default_game_type : _Game, optional + default_managee_type : Type[_Game], optional The default type of game this manager will create. Defaults to None (and then - converted to Game). - available_id_producer : typing.types.FunctionType, optional - Function to produce available game IDs. It will override the built-in class method - get_available_game_id. Defaults to None (and then converted to the built-in - get_available_game_id). + converted to _Game). + + """ + + if default_managee_type is None: + default_managee_type = _Game + super().__init__( + server, + managee_limit=managee_limit, + default_managee_type=default_managee_type + ) + + def get_managee_type(self) -> Type[_Game]: """ + Return the type of the game that will be constructed by default with a call of + `new_managee`. + + Returns + ------- + Type[_Game] + Type of the game. - if default_game_type is None: - default_game_type = _Game - if available_id_producer is None: - available_id_producer = self.get_available_game_id - self.get_available_game_id = available_id_producer + """ - self._server = server - self._playergroup_manager = PlayerGroupManager(server, playergroup_limit=game_limit, - available_id_producer=available_id_producer) - self._default_game_type = default_game_type - self._id_to_game = dict() + return super().get_managee_type() - def new_game(self, game_type=None, creator=None, player_limit=None, - player_concurrent_limit=1, require_invitations=False, require_players=True, - require_leaders=True, require_character=False, team_limit=None, timer_limit=None): + def new_managee( + self, + managee_type: Type[_Game] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + **kwargs, + ) -> _Game: """ Create a new game managed by this manager. Parameters ---------- - game_type : type or functools.partial + managee_type : Type[_Game], optional Class of game that will be produced. Defaults to None (and converted to the default game created by this game manager). - creator : ClientManager.Client, optional + creator : Union[ClientManager.Client, None], optional The player who created this game. If set, they will also be added to the game. Defaults to None. - player_limit : int or None, optional + player_limit : Union[int, None], optional If an int, it is the maximum number of players the game supports. If None, it indicates the game has no player limit. Defaults to None. - player_concurrent_limit : int or None, optional + player_concurrent_limit : Union[int, None], optional If an int, it is the maximum number of games managed by `self` that any player of this game to create may belong to, including this game to create. If None, it indicates that this game does not care about how many other games managed by `self` @@ -1473,17 +2004,20 @@ def new_game(self, game_type=None, creator=None, player_limit=None, If True, if at any point the game has no leaders left, the game will choose a leader among any remaining players left; if no players are left, the next player added will be made leader. If False, no such automatic assignment will happen. Defaults to True. - require_character : bool, optional - If False, players without a character will not be allowed to join the game, and players - that switch to something other than a character will be automatically removed from the - game. If False, no such checks are made. A player without a character is considered - one where player.has_character() returns False. Defaults to False. - team_limit : int or None, optional + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + game, and players that switch to something other than a participant character + will be automatically removed from the game. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional If an int, it is the maximum number of teams the game will support. If None, it indicates the game will have no team limit. Defaults to None. - timer_limit : int or None, optional + timer_limit : Union[int, None], optional If an int, it is the maximum number of timers the game will support. If None, it indicates the game will have no timer limit. Defaults to None. + **kwargs : Any + Additional arguments to consider when producing the game. Returns ------- @@ -1499,61 +2033,128 @@ def new_game(self, game_type=None, creator=None, player_limit=None, """ - game_id = self.get_available_game_id() - def_args = ( - self._server, - self, - game_id, + game = self.unchecked_new_managee( + managee_type=managee_type, + creator=creator, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + # kwargs + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + **kwargs, ) - - def_kwargs = { - 'player_limit': player_limit, - 'player_concurrent_limit': player_concurrent_limit, - 'require_invitations': require_invitations, - 'require_players': require_players, - 'require_leaders': require_leaders, - 'require_character': require_character, - 'team_limit': team_limit, - 'timer_limit': timer_limit, - 'playergroup_manager': self._playergroup_manager, - } - - new_game_type = Constants.make_partial_from(game_type, self._default_game_type, - *def_args, **def_kwargs) - - # Implementation detail - # PlayerGroupError.ManagerTooManyGroupsError cannot be thrown as we overrode the only - # method in there that could have thrown that with something that throws - # GameError.ManagerTooManyGamesError - game = new_game_type() - self._id_to_game[game_id] = game - - # Add creator manually. This is because adding it via .new_group will not make it run - # the add_player code of the game, but only of the internal player group. - try: - if creator: - game.add_player(creator) - except GameError as ex: - # Discard game - self.delete_game(game) - raise ex - self._check_structure() return game - def delete_game(self, game): + def unchecked_new_managee( + self, + managee_type: Type[_Game] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + **kwargs, + ) -> _Game: """ - Delete a game managed by this manager, so all its players no longer belong to this game, - and return the ID and set of players of the former game. + Create a new game managed by this manager. + + This method does not assert structural integrity. Parameters ---------- - game : _Game + managee_type : Type[_Game], optional + Class of game that will be produced. Defaults to None (and converted to the default + game created by this game manager). + creator : Union[ClientManager.Client, None], optional + The player who created this game. If set, they will also be added to the game. + Defaults to None. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the game supports. If None, it + indicates the game has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of games managed by `self` that any player + of this game to create may belong to, including this game to create. If None, it + indicates that this game does not care about how many other games managed by `self` + each of its players belongs to. Defaults to 1 (a player may not be in another game + managed by `self` while in this game). + require_invitations : bool, optional + If True, users can only be added to the game if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the game loses all its players, the game will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the game has no leaders left, the game will choose a leader + among any remaining players left; if no players are left, the next player added will + be made leader. If False, no such automatic assignment will happen. Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + game, and players that switch to something other than a participant character + will be automatically removed from the game. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the game will support. If None, it + indicates the game will have no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the game will support. If None, it + indicates the game will have no timer limit. Defaults to None. + **kwargs : Any + Additional arguments to consider when producing the game. + + Returns + ------- + _Game + The created game. + + Raises + ------ + GameError.ManagerTooManyGamesError + If the manager is already managing its maximum number of games. + Any error from the created game's add_player(creator) + If the game cannot add `creator` as a player if given one. + + """ + + try: + return super().unchecked_new_managee( + managee_type=managee_type, + creator=creator, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + # kwargs + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + **kwargs, + ) + except PlayerGroupError.ManagerTooManyGroupsError: + raise GameError.ManagerTooManyGamesError + + def delete_managee(self, managee: _Game) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a game managed by this manager, so all its players no longer belong to this game. + + Parameters + ---------- + managee : _Game The game to delete. Returns ------- - (str, set of ClientManager.Client) + Tuple[str, Set[ClientManager.Client]] The ID and players of the game that was deleted. Raises @@ -1563,42 +2164,47 @@ def delete_game(self, game): """ - # We have to retrieve the internal playergroup of the game - # We can take advantage of the fact the internal playergroup and the game itself both - # have the same ID by design. + game_id, game_players = self.unchecked_delete_managee(managee) + self._check_structure() + return game_id, game_players - game_id = game.get_id() - if game_id not in self._id_to_game: - raise GameError.ManagerDoesNotManageGameError + def unchecked_delete_managee( + self, + managee: _Game + ) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a game managed by this manager, so all its players no longer belong to this game. - game = self._id_to_game.pop(game_id) - game.destroy() + Parameters + ---------- + managee : _Game + The game to delete. - try: - playergroup = self._playergroup_manager.get_group_by_id(game_id) - playergroup_id, players = self._playergroup_manager.delete_group(playergroup) - except PlayerGroupError.ManagerInvalidGroupIDError: - # This code will only run if this code came as a result of .destroy() on the - # original game doing some work already (namely updating the playergroup manager) and - # a previous call to destroy the playergroup (say, by it losing all its players - # automatically). In this case, the group ID is trivial and there are no players - # (as the group was already destroyed). - playergroup_id, players = game_id, set() + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the game that was deleted. - for player in players: - game.listener.unsubscribe(player) + Raises + ------ + GameError.ManagerDoesNotManageGameError + If the manager does not manage the target game. - self._check_structure() - return playergroup_id, players + """ + + try: + return super().unchecked_delete_managee(managee) + except PlayerGroupError.ManagerDoesNotManageGroupError: + raise GameError.ManagerDoesNotManageGameError - def manages_game(self, game): + def manages_managee(self, game: _Game): """ Return True if the game is managed by this manager, False otherwise. Parameters ---------- game : _Game - The player group to check. + The game to check. Returns ------- @@ -1607,42 +2213,56 @@ def manages_game(self, game): """ - return game in self._id_to_game.values() + return super().manages_managee(game) - def get_default_game_type(self) -> type: + def get_managees(self) -> Set[_Game]: """ - Return the default game the game manager will create with a call of new_game. + Return (a shallow copy of) the games this manager manages. Returns ------- - type - Default game type. + Set[_Game] + Games this manager manages. """ - return self._default_game_type + return super().get_managees() - def get_games(self): + def get_managee_by_id(self, managee_id: str) -> _Game: """ - Return (a shallow copy of) the games this manager manages. + If `managee_id` is the ID of a game managed by this manager, return that. + + Parameters + ---------- + managee_id : str + ID of the game this manager manages. Returns ------- - set of _Game - Games this manager manages. + _Game + The game with that ID. + + Raises + ------ + GameError.ManagerInvalidGameIDError + If `game_id` is not the ID of a game this manager manages. """ - return set(self._id_to_game.values()) + try: + return super().get_managee_by_id(managee_id) + except PlayerGroupError.ManagerInvalidGroupIDError: + raise GameError.ManagerInvalidGameIDError - def get_game_by_id(self, game_id) -> _Game: + def get_managee_by_numerical_id(self, managee_numerical_id: Union[str, int]) -> _Game: """ - If `game_id` is the ID of a game managed by this manager, return that. + If `managee_numerical_id` is the numerical ID of a game managed by this manager, + return the game. Parameters ---------- - game_id : str - ID of the game this manager manages. + managee_numerical_id : Union[str, int] + Numerical ID of the game this manager manages. Returns ------- @@ -1651,43 +2271,70 @@ def get_game_by_id(self, game_id) -> _Game: Raises ------ - GameError.ManagerInvalidGameIDError - If `game_id` is not the ID of a game this manager manages. + GameError.ManagerInvalidGroupIDError: + If `managee_numerical_id` is not the numerical ID of a game + this manager manages. """ try: - return self._id_to_game[game_id] - except KeyError: + return super().get_managee_by_numerical_id(managee_numerical_id) + except PlayerGroupError.ManagerInvalidGroupIDError: raise GameError.ManagerInvalidGameIDError - def get_game_limit(self): + def get_managee_limit(self) -> Union[int, None]: """ Return the game limit of this manager. Returns ------- - int + Union[int, None] Game limit. """ - return self._playergroup_manager.get_group_limit() + return super().get_managee_limit() - def get_game_ids(self): + def get_managee_ids(self) -> Set[str]: """ Return (a shallow copy of) the IDs of all games managed by this manager. Returns ------- - set of str + Set[str] The IDs of all managed games. """ - return set(self._id_to_game.keys()) + return super().get_managee_ids() + + def get_managee_ids_to_managees(self) -> Dict[str, _Game]: + """ + Return a mapping of the IDs of all games managed by this manager to their associated + game. + + Returns + ------- + Dict[str, _Game] + Mapping. + """ + + return super().get_managee_ids_to_managees() + + def get_managee_numerical_ids_to_managees(self) -> Dict[int, _Game]: + """ + Return a mapping of the numerical IDs of all games managed by this manager to their + associated game. + + Returns + ------- + Dict[int, _Game] + Mapping. + """ + + return super().get_managee_numerical_ids_to_managees() - def get_games_of_user(self, user): + def get_managees_of_user(self, user: ClientManager.Client): """ Return (a shallow copy of) the games managed by this manager user `user` is a player of. If the user is part of no such game, an empty set is returned. @@ -1699,31 +2346,107 @@ def get_games_of_user(self, user): Returns ------- - set of _Game + Set[_Game] Games the player belongs to. """ - playergroups = self._playergroup_manager.get_groups_of_user(user) - playergroup_ids = {playergroup.get_id() for playergroup in playergroups} - games = {self._id_to_game[playergroup_id] for playergroup_id in playergroup_ids} - return games + return super().get_managees_of_user(user) + + def get_player_to_managees_map(self) -> Dict[ClientManager.Client, Set[_Game]]: + """ + Return a mapping of the players part of any game managed by this manager to the + game managed by this manager such players belong to. + + Returns + ------- + Dict[ClientManager.Client, Set[_Game]] + Mapping. + """ + + return super().get_player_to_managees_map() - def get_users_in_games(self): + def get_users_in_some_managee(self) -> Set[ClientManager.Client]: """ Return (a shallow copy of) all the users that are part of some game managed by this manager. Returns ------- - set of ClientManager.Client + Set[ClientManager.Client] Users in some managed game. """ - return self._playergroup_manager.get_users_in_groups().copy() + return super().get_users_in_some_managee() + + def is_managee_creatable(self) -> bool: + """ + Return whether a new game can currently be created without creating one. + + Returns + ------- + bool + True if a game can be currently created, False otherwise. + """ + + return super().is_managee_creatable() + + def get_id(self) -> str: + """ + Return the ID of this manager. This ID is guaranteed to be unique among + simultaneously existing Python objects. + + Returns + ------- + str + ID. + + """ + + return super().get_id() + + def find_player_concurrent_limiting_managee( + self, + user: ClientManager.Client + ) -> Union[_Game, None]: + """ + For user `user`, find a game `most_restrictive_game` managed by this manager such + that, if `user` were to join another game managed by this manager, they would + violate `most_restrictive_game`'s concurrent player membership limit. + If no such game exists (or the player is not member of any game managed by this + manager), return None. + If multiple such games exist, any one of them may be returned. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Returns + ------- + Union[_Game, None] + Limiting game as previously described if it exists, None otherwise. + + """ + + return super().find_player_concurrent_limiting_managee(user) + +class GameManager(_GameManagerTrivialInherited): + """ + A game manager is a player group manager. + + Attributes + ---------- + server : TsuserverDR + Server the game manager belongs to. + """ + + # Invariants + # ---------- + # 1. The invariants of the parent class are maintained. - def get_available_game_id(self): + def get_available_managee_id(self): """ Get a game ID that no other game managed by this manager has. @@ -1740,28 +2463,14 @@ def get_available_game_id(self): """ game_number = 0 - game_limit = self.get_game_limit() + game_limit = self.get_managee_limit() while game_limit is None or game_number < game_limit: - new_game_id = "g{}".format(game_number) - if new_game_id not in self.get_game_ids(): + new_game_id = f'g{game_number}' + if new_game_id not in self.get_managee_ids(): return new_game_id game_number += 1 raise GameError.ManagerTooManyGamesError - def get_id(self): - """ - Return the ID of this manager. This ID is guaranteed to be unique among - simultaneously existing managers. - - Returns - ------- - str - ID. - - """ - - return hex(id(self)) - def _check_structure(self): """ Assert that all invariants specified in the class description are maintained. @@ -1772,26 +2481,7 @@ def _check_structure(self): If any of the invariants are not maintained. """ - - # 1. - proper_ids = self._playergroup_manager.get_group_ids() - my_ids = set(self._id_to_game.keys()) - - err = (f'For game manager {self}, expected the set of proper game IDs {proper_ids} and ' - f'its game IDs {my_ids} to be the same, found they were not.') - assert proper_ids == my_ids, err - - # 2. - for (my_id, game) in self._id_to_game.items(): - proper_id = game.get_id() - err = (f'For game manager {self}, expected the proper game ID {proper_id} of game ' - f'{game} and the one the manager appointed for it to be the same, found they ' - f'were not.') - assert proper_id == my_id, err - - # 3. - for game in self._id_to_game.values(): - game._check_structure() + super()._check_structure() def __repr__(self): """ @@ -1804,8 +2494,10 @@ def __repr__(self): """ - return (f"GameManager(server, game_limit={self.get_game_limit()}, " - f"default_game_type={self._default_game_type}, " + return (f"GameManager(server, managee_limit={self.get_managee_limit()}, " + f"default_managee_type={self.get_managee_type()}, " f"|| " - f"_id_to_game={self._id_to_game}, " - f"id={self.get_id()})") + f"_user_to_managees={self.get_player_to_managees_map()}, " + f"_id_to_managee={self.get_managee_ids_to_managees()}, " + f"id={self.get_id()}), " + f')') diff --git a/server/gamewithareas_manager.py b/server/gamewithareas_manager.py index caced54a1..29339bfd0 100644 --- a/server/gamewithareas_manager.py +++ b/server/gamewithareas_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -22,678 +23,2659 @@ from __future__ import annotations -import functools import typing -from typing import Dict, Set, Any +from typing import Callable, Dict, Set, Any, Tuple, Type, Union from server.exceptions import GameWithAreasError, GameError from server.game_manager import _Game, GameManager +from server.timer_manager import Timer if typing.TYPE_CHECKING: # Avoid circular referencing from server.area_manager import AreaManager from server.client_manager import ClientManager + from server.game_manager import _Team + from server.tsuserver import TsuserverDR -class GameWithAreas(_Game): +class _GameWithAreasTrivialInherited(_Game): + """ + This class should not be instantiated. """ - A game with areas is a game that manages and subscribes to its areas' updates. - Any player of such a game must be in an area of the game. If a player of the game goes to an - area not part of the game, they are removed automatically from the game. If an area is removed - from the set of areas of the game, all players in that area are removed in some unspecified - order. - Each of these games may also impose a concurrent area membership limit, so that every area - part of a game with areas is at most an area of that many games with areas managed by this - games's manager. - Each of these games may also set an autoadd on client enter flag. If set, nonplayer clients - who enter an area part of the game will be added to the game if possible; if this fails, - no action is taken and no errors are propagated. - Attributes - ---------- - listener : Listener - Standard listener of the game. + def get_id(self) -> str: + """ + Return the ID of this game with areas. - Callback Methods - ---------------- - _on_area_client_left_final - Method to perform once a client left an area of the game. - _on_area_client_entered_final - Method to perform once a client entered an area of the game. - _on_area_destroyed - Method to perform once an area of the game is marked for destruction. - _on_client_inbound_ms_check - Method to perform once a player of the game wants to send an IC message. - _on_client_inbound_ms_final - Method to perform once a player of the game sends an IC message. - _on_client_change_character - Method to perform once a player of the game has changed character. - _on_client_destroyed - Method to perform once a player of the game is destroyed. + Returns + ------- + str + The ID. - """ + """ - # (Private) Attributes - # -------------------- - # _areas : set of AreaManager.Area - # Areas of the game. - # - # Invariants - # ---------- - # 1. For each player of the game, they are in an area part of the game. - # 2. It is not true that the game requires invitations and automatically adds users that join - # an area part of the game. - # 3. The invariants from the parent class Game are satisfied. + return super().get_id() + + def get_numerical_id(self) -> int: + """ + Return the numerical portion of the ID of this game with areas. + + Returns + ------- + int + Numerical portion of the ID. + """ + + return super().get_numerical_id() - def __init__(self, server, manager, game_id, player_limit=None, - player_concurrent_limit=None, require_invitations=False, require_players=True, - require_leaders=True, require_character=False, team_limit=None, - timer_limit=None, area_concurrent_limit=None, autoadd_on_client_enter=False, - playergroup_manager=None): + def get_name(self) -> str: + """ + Get the name of the game with areas. + + Returns + ------- + str + Name. + """ + + return super().get_name() + + def set_name(self, name: str): """ - Create a new game. A game should not be fully initialized anywhere else other than - some manager code, as otherwise the manager will not recognize the game. + Set the name of the game with areas. Parameters ---------- - server : TsuserverDR - Server the game belongs to. - manager : GameManager - Manager for this game. - game_id : str - Identifier of the game. - player_limit : int or None, optional - If an int, it is the maximum number of players the game supports. If None, it - indicates the game has no player limit. Defaults to None. - player_concurrent_limit : int or None, optional - If an int, it is the maximum number of games managed by `manager` that any - player of this game may belong to, including this game. If None, it indicates - that this game does not care about how many other games managed by `manager` each - of its players belongs to. Defaults to None. - require_invitation : bool, optional - If True, players can only be added to the game if they were previously invited. If - False, no checking for invitations is performed. Defaults to False. - require_players : bool, optional - If True, if at any point the game has no players left, the game will - automatically be deleted. If False, no such automatic deletion will happen. - Defaults to True. - require_leaders : bool, optional - If True, if at any point the game has no leaders left, the game will choose a - leader among any remaining players left; if no players are left, the next player - added will be made leader. If False, no such automatic assignment will happen. - Defaults to True. - require_character : bool, optional - If False, players without a character will not be allowed to join the game, and players - that switch to something other than a character will be automatically removed from the - game. If False, no such checks are made. A player without a character is considered - one where player.has_character() returns False. Defaults to False. - team_limit : int or None, optional - If an int, it is the maximum number of teams the game supports. If None, it - indicates the game has no team limit. Defaults to None. - timer_limit : int or None, optional - If an int, it is the maximum number of timers the game supports. If None, it - indicates the game has no timer limit. Defaults to None. - area_concurrent_limit : int or None, optional - If an int, it is the maximum number of games with areas managed by `manager` that any - area of this game may belong to, including this game. If None, it indicates - that this game does not care about how many other game with areas managed by - `manager` each of its areas belongs to. Defaults to 1 (an area may not be a part of - another game managed by `manager` while being an area of this game). - autoadd_on_client_enter : bool, optional - If True, nonplayer users that enter an area part of the game will be automatically - added if permitted by the conditions of the game. If False, no such adding will take - place. Defaults to False. - playergroup_manager : PlayerGroupManager, optional - The internal playergroup manager of the game manager. Access to this value is - limited exclusively to this __init__, and is only to initialize the internal - player group of the game. + name : str + Name. + """ - Raises - ------ - GameError.ManagerTooManyGamesError - If the manager is already managing its maximum number of games. - GameWithAreasError.AreaHitGameConcurrentLimitError. - If an area in `areas` has reached the concurrent area membership limit of any of the - games it belongs to managed by this manager, or by virtue of adding this area it will - violate this game's concurrent area membership limit. + self.unchecked_set_name(name) + self.manager._check_structure() + def unchecked_set_name(self, name: str): """ + Set the name of the game with areas. - self._areas = set() - self._area_concurrent_limit = area_concurrent_limit - self._autoadd_on_client_enter = autoadd_on_client_enter + This method does not assert structural integrity. + + Parameters + ---------- + name : str + Name. + """ - super().__init__(server, manager, game_id, player_limit=player_limit, - player_concurrent_limit=player_concurrent_limit, - require_invitations=require_invitations, - require_players=require_players, require_leaders=require_leaders, - require_character=require_character, - team_limit=team_limit, timer_limit=timer_limit, - playergroup_manager=playergroup_manager) + super().unchecked_set_name(name) - self.listener.subscribe(self._server.area_manager) - self.listener.update_events({ - 'area_client_left_final': self._on_area_client_left_final, - 'area_client_entered_final': self._on_area_client_entered_final, - 'area_client_inbound_ms_check': self._on_area_client_inbound_ms_check, - 'area_destroyed': self._on_area_destroyed, - 'areas_loaded': self._on_areas_loaded, - }) + def get_player_limit(self) -> Union[int, None]: + """ + Return the player membership limit of this game with areas. + + Returns + ------- + Union[int, None] + The player membership limit. - def get_name(self) -> str: """ - Return the name of the game. Names are fully lowercase. - Implementations of the class should replace this with a human readable name of the trial. + + return super().get_player_limit() + + def get_player_concurrent_limit(self) -> Union[int, None]: + """ + Return the concurrent player membership limit of this game with areas. Returns ------- - str - Name of the game. + Union[int, None] + The concurrent player membership limit. """ - return "game with areas" + return super().get_player_concurrent_limit() - def get_autoadd_on_client_enter(self) -> bool: + def get_players( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ - Return True if the game will always attempt to add nonplayer users who enter an area - part of the game, False otherwise. + Return (a shallow copy of) the set of players of this game with areas that satisfy a + condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all players returned satisfy. Defaults to None (no checked + conditions). Returns ------- - bool - True if the game will always attempt to add nonplayer users who enter an area - part of the game, False otherwise. + Set[ClientManager.Client] + The (filtered) players of this game with areas. + """ - return self._autoadd_on_client_enter + return super().get_players(cond=cond) - def set_autoadd_on_client_enter(self, new_value: bool): + def is_player(self, user: ClientManager.Client) -> bool: """ - Set the new status of the autoadd on client enter flag. + Decide if a user is a player of the game with areas. Parameters ---------- - new_value : bool - New value. + user : ClientManager.Client + User to test. Returns ------- - None. + bool + True if the user is a player, False otherwise. """ - self._autoadd_on_client_enter = new_value + return super().is_player(user) def add_player(self, user: ClientManager.Client): """ - Make a user a player of the game. By default this player will not be a leader. It will - also subscribe the game to the player so it can listen to its updates. + Make a user a player of the game with areas. By default this player will not be a leader, + unless the game with areas has no leaders and it requires a leader. + It will also subscribe the game with areas to the player so it can listen to its updates. Parameters ---------- user : ClientManager.Client - User to add to the game. They must be in an area part of the game. + User to add to the game with areas. They must be in an area part of the game with areas. Raises ------ - GameError.GameIsUnmanagedError - If the game was scheduled for deletion and thus does not accept any mutator + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator public method calls. GameWithAreasError.UserNotInAreaError - If the user is not in an area part of the game. - GameError.UserHasNoCharacterError - If the user has no character but the game requires that all players have characters. - GameError.UserNotInvitedError - If the game requires players be invited to be added and the user is not invited. - GameError.UserAlreadyPlayerError - If the user to add is already a user of the game. - GameError.UserHitGameConcurrentLimitError - If the player has reached any of the games it belongs to managed by this game's - manager concurrent player membership limit, or by virtue of joining this game they - will violate this game's concurrent player membership limit. - GameError.GameIsFullError - If the game reached its player limit. + If the user is not in an area part of the game with areas. + GameWithAreasError.UserHasNoCharacterError + If the user has no character but the game with areas requires that all players have + characters. + GameWithAreasError.UserNotInvitedError + If the game with areas requires players be invited to be added and the user is not + invited. + GameWithAreasError.UserAlreadyPlayerError + If the user to add is already a user of the game with areas. + GameWithAreasError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the game with areas + managed by the manager of this game with areas, or by virtue of joining this + game with areas they would violate this game with areas's concurrent player membership + limit. + GameWithAreasError.GameIsFullError + If the game with areas reached its player limit. """ - if self.is_unmanaged(): - raise GameError.GameIsUnmanagedError - # Check user in area before doing the rest of the add player code. - if user.area not in self._areas: - raise GameWithAreasError.UserNotInAreaError + self.unchecked_add_player(user) + self.manager._check_structure() - super().add_player(user) # Also calls _check_structure() + def remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this game with areas. If they were part of a team + managed by this game with areas, they will also be removed from said team. It will also + unsubscribe the game with areas from the player so it will no longer listen to its updates. - def add_area(self, area: AreaManager.Area): + If the game with areas required that there it always had players and by calling this method + the game with areas had no more players, the game with areas will automatically be scheduled + for deletion. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.UserNotPlayerError + If the user to remove is already not a player of this game with areas. + + """ + + self.unchecked_remove_player(user) + self.manager._check_structure() + + def unchecked_remove_player(self, user: ClientManager.Client): """ - Add an area to this game's set of areas. + Make a user be no longer a player of this game with areas. If they were part of a team + managed by this game with areas, they will also be removed from said team. It will also + unsubscribe the game with areas from the player so it will no longer listen to its updates. + + If the game with areas required that there it always had players and by calling this method + the game with areas had no more players, the game with areas will automatically be scheduled + for deletion. + + This method does not assert structural integrity. Parameters ---------- - area : AreaManager.Area - Area to add. + user : ClientManager.Client + User to remove. Raises ------ - GameError.GameIsUnmanagedError - If the game was scheduled for deletion and thus does not accept any mutator + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator public method calls. - GameWithAreasError.AreaAlreadyInGameError - If the area is already part of the game. - GameWithAreasError.AreaHitGameConcurrentLimitError. - If `area` has reached the concurrent area membership limit of any of the games it - belongs to managed by this manager, or by virtue of adding this area it will violate - this game's concurrent area membership limit. + GameWithAreasError.UserNotPlayerError + If the user to remove is already not a player of this game with areas. + + """ + try: + super().unchecked_remove_player(user) + except GameError.GameIsUnmanagedError: + raise GameWithAreasError.GameIsUnmanagedError + except GameError.UserNotPlayerError: + raise GameWithAreasError.UserNotPlayerError + + def requires_players(self) -> bool: + """ + Return whether the game with areas requires players at all times. Returns ------- - None. + bool + Whether the game with areas requires players at all times. + """ + + return super().requires_players() + def get_invitations( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ + Return (a shallow copy of) the set of invited users of this game with areas that satisfy a + condition if given. - if self.is_unmanaged(): - raise GameError.GameIsUnmanagedError - if area in self._areas: - raise GameWithAreasError.AreaAlreadyInGameError + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all invited users returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) invited users of this game with areas. + + """ + + return super().get_invitations(cond=cond) + + def is_invited(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is invited to the game with areas. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Raises + ------ + GameWithAreasError.UserAlreadyPlayerError + If the user is a player of this game with areas. + + Returns + ------- + bool + True if the user is invited, False otherwise. + + """ - self._areas.add(area) - self.listener.subscribe(area) try: - self._manager._add_area_to_mapping(area, self) - except GameWithAreasError.AreaHitGameConcurrentLimitError as ex: - self._areas.discard(area) - self.listener.unsubscribe(area) - raise ex + return super().is_invited(user) + except GameError.UserAlreadyPlayerError: + raise GameWithAreasError.UserAlreadyPlayerError - self._check_structure() + def add_invitation(self, user: ClientManager.Client): + """ + Mark a user as invited to this game with areas. - def remove_area(self, area: AreaManager.Area): + Parameters + ---------- + user : ClientManager.Client + User to invite to the game with areas. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameDoesNotTakeInvitationsError + If the game with areas does not require users be invited to the game with areas. + GameWithAreasError.UserAlreadyInvitedError + If the player to invite is already invited to the game with areas. + GameWithAreasError.UserAlreadyPlayerError + If the player to invite is already a player of the game with areas. + + """ + + self.unchecked_add_invitation(user) + self.manager._check_structure() + + def unchecked_add_invitation(self, user: ClientManager.Client): """ - Remove an area from this game's set of areas. If the area is already a part of the game, do - nothing. If any player of the game is in this area, they are removed from the game. If the - game has no areas remaining, it will be automatically destroyed. + Mark a user as invited to this game with areas. + + This method does not assert structural integrity. Parameters ---------- - area : AreaManager.Area - Area to remove. + user : ClientManager.Client + User to invite to the game with areas. Raises ------ - GameWithAreasError.AreaNotInGameError - If the area is already not part of the game. + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameDoesNotTakeInvitationsError + If the game with areas does not require users be invited to the game with areas. + GameWithAreasError.UserAlreadyInvitedError + If the player to invite is already invited to the game with areas. + GameWithAreasError.UserAlreadyPlayerError + If the player to invite is already a player of the game with areas. + + """ + + try: + super().unchecked_add_invitation(user) + except GameError.GameIsUnmanagedError: + raise GameWithAreasError.GameIsUnmanagedError + except GameError.GameDoesNotTakeInvitationsError: + raise GameWithAreasError.GameDoesNotTakeInvitationsError + except GameError.UserAlreadyInvitedError: + raise GameWithAreasError.UserAlreadyInvitedError + except GameError.UserAlreadyPlayerError: + raise GameWithAreasError.UserAlreadyPlayerError + + def remove_invitation(self, user: ClientManager.Client): + """ + Mark a user as no longer invited to this game with areas (uninvite). + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameDoesNotTakeInvitationsError + If the game with areas does not require users be invited to the game with areas. + GameWithAreasError.UserNotInvitedError + If the user to uninvite is already not invited to this game with areas. + + """ + + self.unchecked_remove_invitation(user) + self.manager._check_structure() + + def unchecked_remove_invitation(self, user: ClientManager.Client): + """ + Mark a user as no longer invited to this game with areas (uninvite). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameDoesNotTakeInvitationsError + If the game with areas does not require users be invited to the game with areas. + GameWithAreasError.UserNotInvitedError + If the user to uninvite is already not invited to this game with areas. + + """ + + try: + super().unchecked_remove_invitation(user) + except GameError.GameIsUnmanagedError: + raise GameWithAreasError.GameIsUnmanagedError + except GameError.GameDoesNotTakeInvitationsError: + raise GameWithAreasError.GameDoesNotTakeInvitationsError + except GameError.UserNotInvitedError: + raise GameWithAreasError.UserNotInvitedError + + def requires_invitations(self): + """ + Return True if the game with areas requires players be invited before being allowed to join + the game with areas, False otherwise. Returns ------- - None. + bool + True if the game with areas requires players be invited before being allowed to join + the game with areas, False otherwise. + """ + return super().requires_invitations() + + def get_leaders( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ + Return (a shallow copy of) the set of leaders of this game with areas that satisfy a condition + if given. - # No legitimate call of remove_area can happen if the game is unmanaged as it has no areas - # if self.is_unmanaged(): - # raise GameError.GameIsUnmanagedError - if area not in self._areas: - raise GameWithAreasError.AreaNotInGameError + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all leaders returned satisfy. Defaults to None (no checked + conditions). - # Implementation detail: we may not simply check if client.area == area. That is because it - # may be the case a player was moved as a result of the area being destroyed, which is one - # of the events that triggers this method. Moreover, as the change_area code in area reloads - # does not trigger the publishers, we cannot necessarily assume that _on_area_client_left - # will do our checks. - # However, we can check ourselves manually: if a player of the game is in an area not - # part of the game, remove them. - # As area is in self._areas (by earlier check), we do not need to check - faulty_players = self.get_players(cond=lambda client: client.area == area) - for player in faulty_players: - self.remove_player(player) - # Remove area only after removing all players to prevent structural checks failing - self._areas.discard(area) - self.listener.unsubscribe(area) - self._manager._remove_area_from_mapping(area, self) - if not self._areas: - self.destroy() + Returns + ------- + Set[ClientManager.Client] + The (filtered) leaders of this game with areas. + + """ + + return super().get_leaders(cond=cond) + + def get_regulars( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of players of this game with areas that are regulars and + satisfy a condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all regulars returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) regulars of this game with areas. + + """ + + return super().get_regulars(cond=cond) + + def is_leader(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is a leader of the game with areas. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Raises + ------ + GameWithAreasError.UserNotPlayerError + If the player to test is not a player of this game with areas. + + Returns + ------- + bool + True if the player is a user, False otherwise. + + """ + + try: + return super().is_leader(user) + except GameError.UserNotPlayerError: + raise GameWithAreasError.UserNotPlayerError + + def add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this game with areas (promote to leader). + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.UserNotPlayerError + If the player to promote is not a player of this game with areas. + GameWithAreasError.UserAlreadyLeaderError + If the player to promote is already a leader of this game with areas. + + """ + + self.unchecked_add_leader(user) + self.manager._check_structure() + + def unchecked_add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this game with areas (promote to leader). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.UserNotPlayerError + If the player to promote is not a player of this game with areas. + GameWithAreasError.UserAlreadyLeaderError + If the player to promote is already a leader of this game with areas. + + """ + + try: + super().unchecked_add_leader(user) + except GameError.GameIsUnmanagedError: + raise GameWithAreasError.GameIsUnmanagedError + except GameError.UserNotPlayerError: + raise GameWithAreasError.UserNotPlayerError + except GameError.UserAlreadyLeaderError: + raise GameWithAreasError.UserAlreadyLeaderError + + def remove_leader(self, user: ClientManager.Client): + """ + Make a user no longer leader of this game with areas (demote). + + Parameters + ---------- + user : ClientManager.Client + User to demote. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.UserNotPlayerError + If the player to demote is not a player of this game with areas. + GameWithAreasError.UserNotLeaderError + If the player to demote is already not a leader of this game with areas. + + """ + + self.unchecked_remove_leader(user) + self.manager._check_structure() + + def unchecked_remove_leader(self, user: ClientManager.Client): + """ + Make a user no longer leader of this game with areas (demote). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to demote. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.UserNotPlayerError + If the player to demote is not a player of this game with areas. + GameWithAreasError.UserNotLeaderError + If the player to demote is already not a leader of this game with areas. + + """ + + try: + super().unchecked_remove_leader(user) + except GameError.GameIsUnmanagedError: + raise GameWithAreasError.GameIsUnmanagedError + except GameError.UserNotPlayerError: + raise GameWithAreasError.UserNotPlayerError + except GameError.UserNotLeaderError: + raise GameWithAreasError.UserNotLeaderError + + def has_ever_had_players(self) -> bool: + """ + Return True if a player has ever been added to this game with areas, False otherwise. + + Returns + ------- + bool + True if the game with areas has ever had a player added, False otherwise. + + """ + + return super().has_ever_had_players() + + def requires_leaders(self) -> bool: + """ + Return whether the game with areas requires leaders at all times. + + Returns + ------- + bool + Whether the game with areas requires leaders at all times. + """ + + return super().requires_leaders() + + def requires_participant_characters(self) -> bool: + """ + Return whether the game with areas requires players have a participant character at all times. + + Returns + ------- + bool + Whether the game with areas requires players have a participant character at all times. + """ + + return super().requires_participant_characters() + + def new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this game with areas with given parameters. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the game with areas will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameTooManyTimersError + If the game with areas is already managing its maximum number of timers. + + """ + + timer = self.unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + self.manager._check_structure() + return timer + + def unchecked_new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this game with areas with given parameters. + + This method does not assert structural integrity. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the game with areas will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameTooManyTimersError + If the game with areas is already managing its maximum number of timers. + + """ + + try: + return super().unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + except GameError.GameIsUnmanagedError: + raise GameWithAreasError.GameIsUnmanagedError + except GameError.GameTooManyTimersError: + raise GameWithAreasError.GameTooManyTimersError + + def delete_timer(self, timer: Timer) -> str: + """ + Delete a timer managed by this game with areas, terminating it first if needed. + + Parameters + ---------- + timer : Timer + The timer to delete. + + Returns + ------- + str + The ID of the timer that was deleted. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameDoesNotManageTimerError + If the game with areas does not manage the target timer. + + """ + + timer_id = self.unchecked_delete_timer(timer) + self.manager._check_structure() + return timer_id + + def unchecked_delete_timer(self, timer: Timer) -> str: + """ + Delete a timer managed by this game with areas, terminating it first if needed. + + This method does not assert structural integrity. + + Parameters + ---------- + timer : Timer + The timer to delete. + + Returns + ------- + str + The ID of the timer that was deleted. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameDoesNotManageTimerError + If the game with areas does not manage the target timer. + + """ + + try: + return super().unchecked_delete_timer(timer) + except GameError.GameIsUnmanagedError: + raise GameWithAreasError.GameIsUnmanagedError + except GameError.GameDoesNotManageTimerError: + raise GameWithAreasError.GameDoesNotManageTimerError + + def get_timers(self) -> Set[Timer]: + """ + Return (a shallow copy of) the timers this game with areas manages. + + Returns + ------- + Set[Timer] + Timers this game with areas manages. + + """ + + return super().get_timers() + + def get_timer_by_id(self, timer_id: str) -> Timer: + """ + If `timer_tag` is the ID of a timer managed by this game with areas, return that timer. + + Parameters + ---------- + timer_id: str + ID of timer this game with areas manages. + + Returns + ------- + Timer + The timer whose ID matches the given ID. + + Raises + ------ + GameWithAreasError.GameInvalidTimerIDError: + If `timer_tag` is a str and it is not the ID of a timer this game with areas manages. + + """ + + try: + return super().get_timer_by_id(timer_id) + except GameError.GameInvalidTimerIDError: + raise GameWithAreasError.GameInvalidTimerIDError + + def get_timer_limit(self) -> Union[int, None]: + """ + Return the timer limit of this game with areas. + + Returns + ------- + Union[int, None] + Timer limit. + + """ + + return super().get_timer_limit() + + def get_timer_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all timers managed by this game with areas. + + Returns + ------- + Set[str] + The IDs of all managed timers. + + """ + + return super().get_timer_ids() + + def new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this game with areas. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this game with areas. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + + Returns + ------- + _Team + The created team. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameTooManyTeamsError + If the game with areas is already managing its maximum number of teams. + GameWithAreasError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this game with areas. + + """ + + team = self.unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + self.manager._check_structure() + return team + + def unchecked_new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this game with areas. + + This method does not assert structural integrity. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this game with areas. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + + Returns + ------- + _Team + The created team. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameTooManyTeamsError + If the game with areas is already managing its maximum number of teams. + GameWithAreasError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this game with areas. + + """ + + try: + return super().unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + except GameError.GameIsUnmanagedError: + raise GameWithAreasError.GameIsUnmanagedError + except GameError.GameTooManyTeamsError: + raise GameWithAreasError.GameTooManyTeamsError + except GameError.UserInAnotherTeamError: + raise GameWithAreasError.UserInAnotherTeamError + + def delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this game with areas. + + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameDoesNotManageTeamError + If the game with areas does not manage the target team. + + """ + + team_id, players = self.unchecked_delete_team(team) + self.manager._check_structure() + return team_id, players + + def unchecked_delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this game with areas. + + This method does not assert structural integrity. + + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.GameDoesNotManageTeamError + If the game with areas does not manage the target team. + + """ + + try: + return super().unchecked_delete_team(team) + except GameError.GameIsUnmanagedError: + raise GameWithAreasError.GameIsUnmanagedError + except GameError.GameDoesNotManageTeamError: + raise GameWithAreasError.GameDoesNotManageTeamError + + def manages_team(self, team: _Team) -> bool: + """ + Return True if the team is managed by this game with areas, False otherwise. + + Parameters + ---------- + team : _Team + The team to check. + + Returns + ------- + bool + True if the game with areas manages this team, False otherwise. + + """ + + return super().manages_team(team) + + def get_teams(self) -> Set[_Team]: + """ + Return (a shallow copy of) the teams this game with areas manages. + + Returns + ------- + Set[_Team] + Teams this game with areas manages. + + """ + + return super().get_teams() + + def get_team_by_id(self, team_id: str) -> _Team: + """ + If `team_id` is the ID of a team managed by this game with areas, return the team. + + Parameters + ---------- + team_id : str + ID of the team this game with areas manages. + + Returns + ------- + _Team + The team that matches the given ID. + + Raises + ------ + GameWithAreasError.GameInvalidTeamIDError: + If `team_id` is not the ID of a team this game with areas manages. + + """ + + try: + return super().get_team_by_id(team_id) + except GameError.GameInvalidTeamIDError: + raise GameWithAreasError.GameInvalidTeamIDError + + def get_team_limit(self) -> Union[int, None]: + """ + Return the team limit of this game with areas. + + Returns + ------- + Union[int, None] + Team limit. + + """ + + return super().get_team_limit() + + def get_team_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all teams managed by this game with areas. + + Returns + ------- + Set[str] + The IDs of all managed teams. + + """ + + return super().get_team_ids() + + def get_teams_of_user(self, user: ClientManager.Client) -> Set[_Team]: + """ + Return (a shallow copy of) the teams managed by this game with areas user `user` is a player + of. If the user is part of no such team, an empty set is returned. + + Parameters + ---------- + user : ClientManager.Client + User whose teams will be returned. + + Returns + ------- + Set[_Team] + Teams the player belongs to. + + """ + + return super().get_teams_of_user(user) + + def get_users_in_some_team(self): + """ + Return (a shallow copy of) all the users that are part of some team managed by this game + with areas. + + Returns + ------- + Set[ClientManager.Client] + Users in some managed team. + + """ + + return super().get_users_in_some_team() + + def get_available_team_id(self) -> str: + """ + Get a team ID that no other team managed by this team has. + + Returns + ------- + str + A unique team ID. + + Raises + ------ + GameWithAreasError.GameTooManyTeamsError + If the game with areas is already managing its maximum number of teams. + + """ + + try: + return super().get_available_team_id() + except GameError.GameTooManyTeamsError: + raise GameWithAreasError.GameTooManyTeamsError + + def is_unmanaged(self): + """ + Return True if this game with areas is unmanaged, False otherwise. + + Returns + ------- + bool + True if unmanaged, False otherwise. + + """ + + return super().is_unmanaged() + + def destroy(self): + """ + Mark this game with areas as destroyed and notify its manager so that it is deleted. + If the game with areas is already destroyed, this function does nothing. + A game with areas marked for destruction will delete all of its timers, teams, remove all + its players and unsubscribe it from updates of its former players. + + This method is reentrant (it will do nothing though). + + Returns + ------- + None. + + """ + + self.unchecked_destroy() + self.manager._check_structure() + self._check_structure() # Manager will not check this otherwise. + + def _on_client_inbound_ms_check( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): + """ + Default callback for game with areas player signaling it wants to check if sending an IC + message is appropriate. The IC arguments can be passed by reference, so this also serves as + an opportunity to modify the IC message if neeeded. + + To indicate a message should not be sent, some TsuserverException can be raised. The + message of the exception will be sent to the client. + + Parameters + ---------- + player : ClientManager.Client + Player that wants to send the IC message. + contents : Dict[str, Any], optional + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_client_inbound_ms_check(player, contents=contents) + + def _on_client_inbound_ms_final( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): + """ + Default callback for game with areas player signaling it has sent an IC message. + This callback is executed after the server is done making all modifications to the MS packet + sent by the server. + + By default does nothing. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has sent an IC message. + contents : Dict[str, Any], optional + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_client_inbound_ms_final(player, contents=contents) + + def _on_client_change_character( + self, + player: ClientManager.Client, + old_char_id: int = -1, + old_char_name: str = '', + new_char_id: int = -1, + new_char_name: str = '', + ): + """ + Default callback for game with areas player signaling it has changed character. + + By default it only checks if the player is now no longer having a participant character. + If that is the case and the game with areas requires all players have participant + characters, the player is automatically removed. + + Note that it may not necessarily be the case that the following hold: + 1. `old_char_name == player.hub.character_manager.get_character_name(old_char_id)`. + 2. `new_char_name == player.hub.character_manager.get_character_name(new_char_id)`. + This can occur for example if the character list changes, which prompts the player to + change character. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has changed character. + old_char_id : int, optional + Previous character ID. The default is -1. + old_char_name : str, optional + Previous character name. The default is the empty string. + new_char_id : int, optional + New character ID. The default is -1. + new_char_name : int, optional + New character name. The default is the empty string. + + Returns + ------- + None. + + """ + + super()._on_client_change_character( + player, + old_char_id=old_char_id, + old_char_name=old_char_name, + new_char_id=new_char_id, + new_char_name=new_char_name, + ) + + def _on_client_destroyed(self, player: ClientManager.Client): + """ + Default callback for game with areas player signaling it was destroyed, for example, as a + result of a disconnection. + + By default it only removes the player from the game with areas. If the game with areas is + already unmanaged or the player is not in the game with areas, this callback does nothing. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it was destroyed. + + Returns + ------- + None. + + """ + + super()._on_client_destroyed(player) + +class _GameWithAreas(_GameWithAreasTrivialInherited): + """ + A game with areas is a game that manages and subscribes to its areas' updates. + Any player of such a game with areas must be in an area of the game with areas. If a player of + the game with areas goes to an area not part of the game with areas, they are removed + automatically from the game with areas. + + If an area is removed from the set of areas of the game with areas, all players in that area + are removed in some unspecified order. + + Each of these games with areas may also impose a concurrent area membership limit, so that + every area part of a game with areas is at most an area of that many games with areas managed + by this games's manager. + + Each of these games with areas may also set an autoadd on client enter flag. If set, nonplayer + clients who enter an area part of the game with areas will be added to the game with areas if + possible; if this fails, no action is taken and no errors are propagated. + + Each of these games with areas may also require that it never loses all its areas as soon as it + gets its first one (or else it is automatically deleted). + + Attributes + ---------- + server : TsuserverDR + Server the game with areas belongs to. + manager : GameWithAreasManager + Manager for this game with areas. + listener : Listener + Standard listener of the game with areas. + + Callback Methods + ---------------- + _on_area_client_left_final + Method to perform once a client left an area of the game with areas. + _on_area_client_entered_final + Method to perform once a client entered an area of the game with areas. + _on_area_destroyed + Method to perform once an area of the game with areas is marked for destruction. + _on_client_inbound_ms_check + Method to perform once a player of the game with areas wants to send an IC message. + _on_client_inbound_ms_final + Method to perform once a player of the game with areas sends an IC message. + _on_client_change_character + Method to perform once a player of the game with areas has changed character. + _on_client_destroyed + Method to perform once a player of the game with areas is destroyed. + + """ + + # (Private) Attributes + # -------------------- + # _areas : Set[AreaManager.Area] + # Areas of the game with areas. + # _area_concurrent_limit : Union[int, None] + # The maximum number of games with areas managed by `manager` that any + # area of this game with areas may belong to, including this game with areas. + # _autoadd_on_client_enter : bool + # Whether nonplayer users that enter an area part of the game with areas will be + # automatically added if permitted by the conditions of the game with areas. + # _ever_had_areas : bool + # If True, at least once has a area been added successfully the game with areas; + # otherwise False. + # + # Invariants + # ---------- + # 1. For each player of the game with areas, they are in an area part of the game with areas. + # 2. It is not true that the game with areas requires invitations and automatically adds users + # that join an area part of the game with areas. + # 3. If `self._require_areas` is True, then `len(self._areas) >= 1 or self.is_unmanaged()`. + # 4. The invariants from the parent class _Game are satisfied. + + def __init__( + self, + server: TsuserverDR, + manager: GameWithAreasManager, + game_id: str, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + area_concurrent_limit: Union[int, None] = None, + autoadd_on_client_enter: bool = False, + require_areas: bool = True, + ): + """ + Create a new game with areas. A game with areas should not be fully initialized anywhere + else other than some manager code, as otherwise the manager will not recognize the + game with areas. + + Parameters + ---------- + server : TsuserverDR + Server the game with areas belongs to. + manager : GameWithAreasManager + Manager for this game with areas. + game_id : str + Identifier of the game with areas. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the game with areas supports. If None, it + indicates the game with areas has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of games with areas managed by `manager` that any + player of this game with areas may belong to, including this game with areas. If None, + it indicates that this game with areas does not care about how many other games with + areas managed by `manager` each of its players belongs to. Defaults to None. + require_invitation : bool, optional + If True, players can only be added to the game with areas if they were previously + invited. If False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the game with areas has no players left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + require_leaders : bool, optional + If True, if at any point the game with areas has no leaders left, the game with areas + will choose a leader among any remaining players left; if no players are left, the next + player added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + game with areas, and players that switch to something other than a participant character + will be automatically removed from the game with areas. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the game with areas supports. If None, it + indicates the game with areas has no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the game with areas supports. If None, it + indicates the game with areas has no timer limit. Defaults to None. + area_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of games with areas managed by `manager` that any + area of this game with areas may belong to, including this game with areas. If None, it + indicates that this game with areas does not care about how many other game with areas + managed by `manager` each of its areas belongs to. Defaults to 1 (an area may not be a + part of another game with areas managed by `manager` while being an area of this game). + autoadd_on_client_enter : bool, optional + If True, nonplayer users that enter an area part of the game with areas will be + automatically added if permitted by the conditions of the game with areas. If False, no + such adding will take place. Defaults to False. + require_areas : bool, optional + If True, if at any point the game with areas has no areas left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + + """ + + self._areas = set() + self._area_concurrent_limit = area_concurrent_limit + self._autoadd_on_client_enter = autoadd_on_client_enter + self._require_areas = require_areas + + super().__init__( + server, + manager, + game_id, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit + ) + + self.listener.update_events({ + 'area_client_left_final': self._on_area_client_left_final, + 'area_client_entered_final': self._on_area_client_entered_final, + 'area_client_inbound_ms_check': self._on_area_client_inbound_ms_check, + 'area_destroyed': self._on_area_destroyed, + }) + self._ever_had_areas = False + + self.manager: GameWithAreasManager # Setting for typing + + def get_type_name(self) -> str: + """ + Return the type name of the game with areas. Names are fully lowercase. + Implementations of the class should replace this with a human readable name of the game + with areas. + + Returns + ------- + str + Type name of the game with areas. + + """ + + return "game with areas" + + def get_autoadd_on_client_enter(self) -> bool: + """ + Return True if the game with areas will always attempt to add nonplayer users who enter an + area part of the game with areas, False otherwise. + + Returns + ------- + bool + True if the game with areas will always attempt to add nonplayer users who enter an area + part of the game with areas, False otherwise. + """ + + return self._autoadd_on_client_enter + + def set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + Parameters + ---------- + new_value : bool + New value. + + """ + + self.unchecked_set_autoadd_on_client_enter(new_value) + self.manager._check_structure() + + def unchecked_set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + This method does not assert structural integrity. + + Parameters + ---------- + new_value : bool + New value. + + """ + + self._autoadd_on_client_enter = new_value + + def unchecked_add_player(self, user: ClientManager.Client): + """ + Make a user a player of the game with areas. By default this player will not be a leader, + unless the game with areas has no leaders and it requires a leader. + It will also subscribe the game with areas to the player so it can listen to its updates. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to add to the game with areas. They must be in an area part of the game with areas. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.UserNotInAreaError + If the user is not in an area part of the game with areas. + GameWithAreasError.UserHasNoCharacterError + If the user has no character but the game with areas requires that all players have + characters. + GameWithAreasError.UserNotInvitedError + If the game with areas requires players be invited to be added and the user is not + invited. + GameWithAreasError.UserAlreadyPlayerError + If the user to add is already a user of the game with areas. + GameWithAreasError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the game with areas + managed by the manager of this game with areas, or by virtue of joining this + game with areas they would violate this game with areas's concurrent player membership + limit. + GameWithAreasError.GameIsFullError + If the game with areas reached its player limit. + + """ + + if self.is_unmanaged(): + raise GameWithAreasError.GameIsUnmanagedError + # Check user in area before doing the rest of the add player code. + if user.area not in self._areas: + raise GameWithAreasError.UserNotInAreaError + + try: + super().unchecked_add_player(user) + except GameError.GameIsUnmanagedError: + raise RuntimeError(self) + except GameError.UserHasNoCharacterError: + raise GameWithAreasError.UserHasNoCharacterError + except GameError.UserNotInvitedError: + raise GameWithAreasError.UserNotInvitedError + except GameError.UserAlreadyPlayerError: + raise GameWithAreasError.UserAlreadyPlayerError + except GameError.UserHitGameConcurrentLimitError: + raise GameWithAreasError.UserHitGameConcurrentLimitError + except GameError.GameIsFullError: + raise GameWithAreasError.GameIsFullError + + def add_area(self, area: AreaManager.Area): + """ + Add an area to this game with areas's set of areas. + + Parameters + ---------- + area : AreaManager.Area + Area to add. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.AreaAlreadyInGameError + If the area is already part of the game with areas. + GameWithAreasError.AreaHitGameConcurrentLimitError. + If `area` has reached the concurrent area membership limit of any of the games with + areas it belongs to managed by this manager, or by virtue of adding this area it will + violate this game with areas's concurrent area membership limit. + + """ + + self.unchecked_add_area(area) + self.manager._check_structure() + + def unchecked_add_area(self, area: AreaManager.Area): + """ + Add an area to this game with areas's set of areas. + + This method does not assert structural integrity. + + Parameters + ---------- + area : AreaManager.Area + Area to add. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.AreaAlreadyInGameError + If the area is already part of the game with areas. + GameWithAreasError.AreaHitGameConcurrentLimitError. + If `area` has reached the concurrent area membership limit of any of the games with + areas it belongs to managed by this manager, or by virtue of adding this area it will + violate this game with areas's concurrent area membership limit. + + """ + + if self.is_unmanaged(): + raise GameWithAreasError.GameIsUnmanagedError + if area in self._areas: + raise GameWithAreasError.AreaAlreadyInGameError + if self.manager.find_area_concurrent_limiting_managee(area): + raise GameWithAreasError.AreaHitGameConcurrentLimitError + games_of_area = self.manager.get_managees_in_area(area) + if len(games_of_area) >= self._area_concurrent_limit: + raise GameWithAreasError.AreaHitGameConcurrentLimitError + + # At this point, the area add will be successful + + self._ever_had_areas = True + self._areas.add(area) + self.listener.subscribe(area) + + def remove_area(self, area: AreaManager.Area): + """ + Remove an area from this game with areas's set of areas. + If the area is already a part of the game with areas, do nothing. + If any player of the game with areas is in this area, they are removed from the + game with areas. + If the game with areas has no areas remaining, it will be automatically destroyed. + + Parameters + ---------- + area : AreaManager.Area + Area to remove. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.AreaNotInGameError + If the area is already not part of the game with areas. + + """ + + self.unchecked_remove_area(area) + self.manager._check_structure() + + def unchecked_remove_area(self, area: AreaManager.Area): + """ + Remove an area from this game with areas's set of areas. + If the area is already a part of the game with areas, do nothing. + If any player of the game with areas is in this area, they are removed from the + game with areas. + If the game with areas has no areas remaining, it will be automatically destroyed. + + This method does not assert structural integrity. + + Parameters + ---------- + area : AreaManager.Area + Area to remove. + + Raises + ------ + GameWithAreasError.GameIsUnmanagedError + If the game with areas was scheduled for deletion and thus does not accept any mutator + public method calls. + GameWithAreasError.AreaNotInGameError + If the area is already not part of the game with areas. + + """ + + if self.is_unmanaged(): + raise GameWithAreasError.GameIsUnmanagedError + if area not in self._areas: + raise GameWithAreasError.AreaNotInGameError + + # Implementation detail: we may not simply check if client.area == area. That is because it + # may be the case a player was moved as a result of the area being destroyed, which is one + # of the events that triggers this method. Moreover, as the change_area code in area reloads + # does not trigger the publishers, we cannot necessarily assume that _on_area_client_left + # will do our checks. + # However, we can check ourselves manually: if a player of the game with areas is in an + # area not part of the game with areas, remove them. + # As area is in self._areas (by earlier check), we do not need to check + faulty_players = self.get_players(cond=lambda client: client.area == area) + for player in faulty_players: + self.unchecked_remove_player(player) + + # Remove area only after removing all players to prevent structural checks failing + self._cleanup_remove_area(area) + + if not self._areas and self._require_areas: + self.unchecked_destroy() + + def _cleanup_remove_area(self, area: AreaManager.Area): + self._areas.discard(area) + self.listener.unsubscribe(area) + + def requires_areas(self) -> bool: + """ + Return whether the game with areas requires areas at all times. + + Returns + ------- + bool + Whether the game with areas requires areas at all times. + """ + + return self._require_areas + + def has_area(self, area: AreaManager.Area) -> bool: + """ + If the area is part of this game with areas's set of areas, return True; otherwise, return + False. + + Parameters + ---------- + area : AreaManager.Area + Area to check. + + Returns + ------- + bool + True if the area is part of the game with areas's set of areas, False otherwise. + + """ + + return area in self._areas + + def get_areas(self) -> Set[AreaManager.Area]: + """ + Return (a shallow copy of) the set of areas of this game with areas. + + Returns + ------- + Set[AreaManager.Area] + Set of areas of the game with areas. + + """ + + return self._areas.copy() + + def get_area_concurrent_limit(self) -> Union[int, None]: + """ + Return the concurrent area membership limit of this game with areas. + + Returns + ------- + Union[int, None] + The concurrent area membership limit. + + """ + + return self._area_concurrent_limit + + def get_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the game with areas, even those that are not players of + the game with areas. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the game with areas. + + """ + + clients = list() + for area in self._areas: + clients.extend(area.clients) + return set(clients) + + def get_nonleader_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the game with areas, even those that are not players of + the game with areas, such that they are not leaders of the game with areas. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the game with areas that are not leaders of the game with areas. + + """ + + return {client for client in self.get_users_in_areas() + if not (self.is_player(client) and self.is_leader(client))} + + def get_nonplayer_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the game with areas that are not players of the + game with areas. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the game with areas that are not players of the + game with areas. + + """ + + return {client for client in self.get_users_in_areas() if not self.is_player(client)} + + def unchecked_destroy(self): + """ + Mark this game with areas as destroyed and notify its manager so that it is deleted. + If the game with areas is already destroyed, this function does nothing. + + This method is reentrant (it will do nothing though). + + Returns + ------- + None. + + """ + + areas = self.get_areas() + # Remove areas too. This is done first so that structural checks can take place after + # areas are removed. + # for area in self.get_areas(): + # self.unchecked_remove_area(area) + super().unchecked_destroy() + for area in areas: + self._cleanup_remove_area(area) + + def _on_area_client_left_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False, + ): + """ + Default callback for game with areas area signaling a client left. This is executed after + all other actions related to moving the player to a new area have been executed: + in particular, client.area holds the new area of the client. + + By default it removes the player from the game with areas if their new area is not part of + the game with areas. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has left. + client : ClientManager.Client, optional + The client that has left. The default is None. + new_area : AreaManager.Area + The new area the client has gone to. The default is None. + old_displayname : str, optional + The old displayed name of the client before they changed area. This will typically + change only if the client's character or showname are taken. The default is None. + ignore_bleeding : bool, optional + If the code should ignore actions regarding bleeding. The default is False. + ignore_autopass : bool, optional + If the code should ignore actions regarding autopass. The default is False. + + Returns + ------- + None. + + """ + + # print('Received LEFT', area, client, client.area, old_displayname, ignore_bleeding) + if client in self.get_players() and client.area not in self._areas: + self.remove_player(client) + + def _on_area_client_entered_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_area: Union[AreaManager.Area, None] = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False, + ): + """ + Default callback for game with areas area signaling a client entered. + + By default adds a user to the game with areas if the game with areas is meant to + automatically add users that enter an area part of the game with areas. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has entered. + client : ClientManager.Client, optional + The client that has entered. The default is None. + old_area : AreaManager.Area + The old area the client has come from (possibly None for a newly connected user). The + default is None. + old_displayname : str, optional + The old displayed name of the client before they changed area. This will typically + change only if the client's character or showname are taken. The default is None. + ignore_bleeding : bool, optional + If the code should ignore actions regarding bleeding. The default is False. + ignore_autopass : bool, optional + If the code should ignore actions regarding autopass. The default is False. + + Returns + ------- + None. + + """ + + # print('Received ENTERED', area, client, old_area, old_displayname, ignore_bleeding) + if client not in self.get_players() and self.get_autoadd_on_client_enter(): + self.add_player(client) + + def _on_area_client_inbound_ms_check( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + contents: Dict[str, Any] = None + ): + """ + Default callback for game with areas area signaling a client in the area sent an IC message. + Unlike the ClientManager.Client callback for send_ic_check, this one is triggered + regardless of whether the sender is part of the game with areas or not. This is useful for + example, to filter out messages sent by non-players. + + By default does nothing. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has entered. + client : ClientManager.Client, optional + The client that has send the IC message. The default is None. + contents : Dict[str, Any] + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + # print('User', client, 'in area', area, 'wants to check sent', contents) + + def _on_area_destroyed(self, area: AreaManager.Area): + """ + Default callback for game with areas area signaling it was destroyed. + + By default it calls self.unchecked_remove_area(area). + + Parameters + ---------- + area : AreaManager.Area + Area that signaled it was destroyed. + + Returns + ------- + None. + + """ + + # print('Received DESTRUCTION', area) + self.remove_area(area) + + def _check_structure(self): + """ + Assert that all invariants specified in the class description are maintained. + + Raises + ------ + AssertionError + If any of the invariants are not maintained. + + """ + + # 1. + for player in self.get_players(): + assert player.area in self._areas, ( + f'For game with areas {self}, expected that its player {player} was in an part of ' + f'the game with areas, found they were in area {player.area} instead.' + ) + + # 2. + if self.requires_invitations(): + assert self._autoadd_on_client_enter, ( + f'For game with areas {self}, expected that it did not simultaneously require ' + f'invitations for users to join while mandating users be automatically added ' + f'if they enter an area of the game with areas, found it did.' + ) + + # 3. + if self._require_areas and self._ever_had_areas: + assert self._areas or self.is_unmanaged(), ( + f'For game with areas {self}, expected that it was scheduled for ' + f'deletion after losing all its areas, but found it was not.' + ) + + # 4. + super()._check_structure() + + def __str__(self) -> str: + """ + Return a string representation of this game with areas. + + Returns + ------- + str + Representation. + + """ + + return (f"GameWithAreas::{self.get_id()}:" + f"{self.get_players()}:{self.get_leaders()}:{self.get_invitations()}" + f"{self.get_timers()}:" + f"{self.get_teams()}:" + f"{self.get_areas()}") + + def __repr__(self) -> str: + """ + Return a representation of this game with areas. + + Returns + ------- + str + Printable representation. + + """ + + return (f'GameWithAreas(server, {self.manager.get_id()}, "{self.get_id()}", ' + f'player_limit={self.get_player_limit()}, ' + f'player_concurrent_limit={self.get_player_concurrent_limit()}, ' + f'require_players={self.requires_players()}, ' + f'require_invitations={self.requires_invitations()}, ' + f'require_leaders={self.requires_leaders()}, ' + f'require_participant_character={self.requires_participant_characters()}, ' + f'team_limit={self.get_team_limit()}, ' + f'timer_limit={self.get_timer_limit()}, ' + f'areas={self.get_areas()}), ' + f'|| ' + f'players={self.get_players()}, ' + f'invitations={self.get_invitations()}, ' + f'leaders={self.get_leaders()}, ' + f'timers={self.get_timers()}, ' + f'teams={self.get_teams()}, ' + f'unmanaged={self.is_unmanaged()}), ' + f')') - self._check_structure() +class _GameWithAreasManagerTrivialInherited(GameManager): + """ + This class should not be instantiated. + """ - def has_area(self, area: AreaManager.Area): + def new_managee( + self, + managee_type: Type[_GameWithAreas] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + areas: Set[AreaManager.Area] = None, + area_concurrent_limit: Union[int, None] = None, + autoadd_on_client_enter: bool = False, + autoadd_on_creation_existing_users: bool = False, + require_areas: bool = True, + **kwargs: Any, + ) -> _GameWithAreas: """ - If the area is part of this game's set of areas, return True; otherwise, return False. + Create a new game with areas managed by this manager. Parameters ---------- - area : AreaManager.Area - Area to check. + managee_type : Type[_GameWithAreas], optional + Class of game with areas that will be produced. Defaults to None (and converted to the + default game with areas created by this game with areas manager). + creator : Union[ClientManager.Client, None], optional + The player who created this game with areas. If set, they will also be added to the + game with areas. Defaults to None. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the game with areas supports. If None, it + indicates the game with areas has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of games with areas managed by `self` that any + player of this game with areas to create may belong to, including this game with areas + to create. If None, it indicates that this game with areas does not care about how many + other games with areas managed by `self` each of its players belongs to. Defaults to 1 + (a player may not be in another game managed by `self` while in this game). + require_invitations : bool, optional + If True, users can only be added to the game with areas if they were previously invited. + If False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the game with areas loses all its players, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + require_leaders : bool, optional + If True, if at any point the game with areas has no leaders left, the game with areas + will choose a leader among any remaining players left; if no players are left, the next + player added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + game with areas, and players that switch to something other than a participant character + will be automatically removed from the game with areas. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the game with areas will support. If None, + it indicates the game with areas will have no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the game with areas will support. If None, + it indicates the game with areas will have no timer limit. Defaults to None. + areas : Set[AreaManager.Area], optional + The areas to add to the game with areas when creating it. Defaults to None (and + converted to a set containing the creator's area if given a creator, and the empty set + otherwise). + area_concurrent_limit : Union[int, None] + The concurrent area membership limit of this game with areas. Defaults to None. + autoadd_on_client_enter : bool + If the game with areas will always attempt to add nonplayer users who enter an area + part of the game with areas. Defaults to False. + autoadd_on_creation_existing_users : bool + If the game with areas will attempt to add nonplayer users who were in an area added + to the game with areas on creation. Defaults to False. + require_areas : bool, optional + If True, if at any point the game with areas has no areas left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + **kwargs : Any + Additional arguments to consider when producing the game with areas. Returns ------- - bool - True if the area is part of the game's set of areas, False otherwise. + _GameWithAreas + The created game with areas. + + Raises + ------ + GameWithAreasError.ManagerTooManyGamesError + If the manager is already managing its maximum number of games. + Any error from the created game with areas's add_player(creator) + If the game with areas cannot add `creator` as a player if given one. """ - return area in self._areas + game = self.unchecked_new_managee( + managee_type=managee_type, + creator=creator, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + # kwargs + areas=areas, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + autoadd_on_creation_existing_users=autoadd_on_creation_existing_users, + require_area=require_areas, + **kwargs, + ) + self._check_structure() + return game - def get_areas(self) -> Set[AreaManager.Area]: + def get_managee_type(self) -> Type[_GameWithAreas]: """ - Return (a shallow copy of) the set of areas of this game. + Return the type of the game with areas that will be constructed by default with a call of + `new_managee`. Returns ------- - set of AreaManager.Area - Set of areas of the game. + Type[_GameWithAreas] + Type of the game with areas. """ - return self._areas.copy() + return super().get_managee_type() - def get_area_concurrent_limit(self): + def delete_managee(self, managee: _GameWithAreas) -> Tuple[str, Set[ClientManager.Client]]: """ - Return the concurrent area membership limit of this game. + Delete a game with areas managed by this manager, so all its players no longer belong to + this game with areas. + + Parameters + ---------- + managee : _GameWithAreas + The game with areas to delete. Returns ------- - int or None - The concurrent area membership limit. + Tuple[str, Set[ClientManager.Client]] + The ID and players of the game with areas that was deleted. + + Raises + ------ + GameWithAreasError.ManagerDoesNotManageGameError + If the manager does not manage the target game with areas. """ - return self._area_concurrent_limit + game_id, game_players = self.unchecked_delete_managee(managee) + self._check_structure() + return game_id, game_players - def get_users_in_areas(self) -> Set[ClientManager.Client]: + def unchecked_delete_managee( + self, + managee: _GameWithAreas + ) -> Tuple[str, Set[ClientManager.Client]]: """ - Return all users in areas part of the game, even those that are not players of the game. + Delete a game with areas managed by this manager, so all its players no longer belong to + this game with areas. + + Parameters + ---------- + managee : _GameWithAreas + The game with areas to delete. Returns ------- - users : set of ClientManager.Client - All users in areas part of the game. + Tuple[str, Set[ClientManager.Client]] + The ID and players of the game with areas that was deleted. + + Raises + ------ + GameWithAreasError.ManagerDoesNotManageGameError + If the manager does not manage the target game with areas. """ - clients = list() - for area in self._areas: - clients.extend(area.clients) - return set(clients) + try: + return super().unchecked_delete_managee(managee) + except GameError.ManagerDoesNotManageGameError: + raise GameWithAreasError.ManagerDoesNotManageGameError - def get_nonleader_users_in_areas(self) -> Set[ClientManager.Client]: + def manages_managee(self, game: _GameWithAreas): """ - Return all users in areas part of the game, even those that are not players of the game, - such that they are not leaders of the game. + Return True if the game with areas is managed by this manager, False otherwise. + + Parameters + ---------- + game : _GameWithAreas + The game to check. Returns ------- - users : set of ClientManager.Client - All users in areas part of the game that are not leaders of the game. + bool + True if the manager manages this game with areas, False otherwise. """ - return {client for client in self.get_users_in_areas() - if not (self.is_player(client) and self.is_leader(client))} + return super().manages_managee(game) - def get_nonplayer_users_in_areas(self) -> Set[ClientManager.Client]: + def get_managees(self) -> Set[_GameWithAreas]: """ - Return all users in areas part of the game that are not players of the game. + Return (a shallow copy of) the games with areas this manager manages. Returns ------- - users : set of ClientManager.Client - All users in areas part of the game that are not players of the game. + Set[_GameWithAreas] + Games with areas this manager manages. """ - return {client for client in self.get_users_in_areas() if not self.is_player(client)} + return super().get_managees() - def destroy(self): + def get_managee_by_id(self, managee_id: str) -> _GameWithAreas: """ - Mark this game as destroyed and notify its manager so that it is deleted. - If the game is already destroyed, this function does nothing. + If `managee_id` is the ID of a game with areas managed by this manager, return that. - This method is reentrant (it will do nothing though). + Parameters + ---------- + managee_id : str + ID of the game with areas this manager manages. Returns ------- - None. + _GameWithAreas + The game with areas with that ID. + + Raises + ------ + GameWithAreasError.ManagerInvalidGameIDError + If `game_id` is not the ID of a game with areas this manager manages. """ - # Remove areas too. This is done first so that structural checks can take place after - # areas are removed. - for area in self.get_areas(): - self.remove_area(area) - super().destroy() # Also calls _check_structure() + try: + return super().get_managee_by_id(managee_id) + except GameError.ManagerInvalidGameIDError: + raise GameWithAreasError.ManagerInvalidGameIDError - def __str__(self) -> str: + def get_managee_by_numerical_id(self, managee_numerical_id: Union[str, int]) -> _GameWithAreas: """ - Return a string representation of this game. + If `managee_numerical_id` is the numerical ID of a game with areas managed by this manager, + return the game with areas. + + Parameters + ---------- + managee_numerical_id : Union[str, int] + Numerical ID of the game with areas this manager manages. Returns ------- - str - Representation. + _GameWithAreas + The game with areas with that ID. + + Raises + ------ + GameWithAreasError.ManagerInvalidGameIDError: + If `managee_numerical_id` is not the numerical ID of a game with areas + this manager manages. """ - return (f"GameWithAreas::{self.get_id()}:" - f"{self.get_players()}:{self.get_leaders()}:{self.get_invitations()}" - f"{self.get_timers()}:" - f"{self.get_teams()}:" - f"{self.get_areas()}") + try: + return super().get_managee_by_numerical_id(managee_numerical_id) + except GameError.ManagerInvalidGameIDError: + raise GameWithAreasError.ManagerInvalidGameIDError - def __repr__(self) -> str: + def get_managee_limit(self) -> Union[int, None]: """ - Return a representation of this game. + Return the game with areas limit of this manager. Returns ------- - str - Printable representation. + Union[int, None] + Game with areas limit. """ - return (f'GameWithAreas(server, {self._manager.get_id()}, "{self.get_id()}", ' - f'player_limit={self._playergroup._player_limit}, ' - f'player_concurrent_limit={self.get_player_concurrent_limit()}, ' - f'require_players={self._playergroup._require_players}, ' - f'require_invitations={self._playergroup._require_invitations}, ' - f'require_leaders={self._playergroup._require_leaders}, ' - f'require_character={self._require_character}, ' - f'team_limit={self._team_manager.get_group_limit()}, ' - f'timer_limit={self._timer_manager.get_timer_limit()}, ' - f'areas={self.get_areas()}) || ' - f'players={self.get_players()}, ' - f'invitations={self.get_invitations()}, ' - f'leaders={self.get_leaders()}, ' - f'timers={self.get_timers()}, ' - f'teams={self.get_teams()}') + return super().get_managee_limit() - def _on_area_client_left_final(self, area, client=None, old_displayname=None, - ignore_bleeding=False, ignore_autopass=False): + def get_managee_ids(self) -> Set[str]: """ - Default callback for game area signaling a client left. This is executed after all other - actions related to moving the player to a new area have been executed: in particular, - client.area holds the new area of the client. - - By default it removes the player from the game if their new area is not part of the game. - - Parameters - ---------- - area : AreaManager.Area - Area that signaled a client has left. - client : ClientManager.Client, optional - The client that has left. The default is None. - new_area : AreaManager.Area - The new area the client has gone to. The default is None. - old_displayname : str, optional - The old displayed name of the client before they changed area. This will typically - change only if the client's character or showname are taken. The default is None. - ignore_bleeding : bool, optional - If the code should ignore actions regarding bleeding. The default is False. - ignore_autopass : bool, optional - If the code should ignore actions regarding autopass. The default is False. + Return (a shallow copy of) the IDs of all games with areas managed by this manager. Returns ------- - None. + Set[str] + The IDs of all managed games with areas. """ - # print('Received LEFT', area, client, client.area, old_displayname, ignore_bleeding) - if client in self.get_players() and client.area not in self._areas: - self.remove_player(client) + return super().get_managee_ids() - self._check_structure() + def get_managee_ids_to_managees(self) -> Dict[str, _GameWithAreas]: + """ + Return a mapping of the IDs of all games with areas managed by this manager to their + associated game with areas. - def _on_area_client_entered_final(self, area: AreaManager.Area, - client: ClientManager.Client = None, - old_area: AreaManager.Area = None, - old_displayname: str = None, - ignore_bleeding: bool = False, - ignore_autopass: bool = False): + Returns + ------- + Dict[str, _GameWithAreas] + Mapping. """ - Default callback for game area signaling a client entered. - By default adds a user to the game if the game is meant to automatically add users that - enter an area part of the game. + return super().get_managee_ids_to_managees() - Parameters - ---------- - area : AreaManager.Area - Area that signaled a client has entered. - client : ClientManager.Client, optional - The client that has entered. The default is None. - old_area : AreaManager.Area - The old area the client has come from. The default is None. - old_displayname : str, optional - The old displayed name of the client before they changed area. This will typically - change only if the client's character or showname are taken. The default is None. - ignore_bleeding : bool, optional - If the code should ignore actions regarding bleeding. The default is False. - ignore_autopass : bool, optional - If the code should ignore actions regarding autopass. The default is False. + def get_managee_numerical_ids_to_managees(self) -> Dict[int, _GameWithAreas]: + """ + Return a mapping of the numerical IDs of all game with areas managed by this manager to + their associated game with areas. Returns ------- - None. - + Dict[int, _GameWithAreas] + Mapping. """ - # print('Received ENTERED', area, client, old_area, old_displayname, ignore_bleeding) - if client not in self.get_players() and self.get_autoadd_on_client_enter(): - self.add_player(client) - - self._check_structure() + return super().get_managee_numerical_ids_to_managees() - def _on_area_client_inbound_ms_check(self, area: AreaManager.Area, - client: ClientManager.Client = None, - contents: Dict[str, Any] = None): + def get_managees_of_user(self, user: ClientManager.Client): """ - Default callback for game area signaling a client in the area sent an IC message. Unlike - the ClientManager.Client callback for send_ic_check, this one is triggered regardless of - whether the sender is part of the game or not. This is useful for example, to filter - out messages sent by non-players. - - By default does nothing. + Return (a shallow copy of) the games with areas managed by this manager user `user` is a + player of. If the user is part of no such game with areas, an empty set is returned. Parameters ---------- - area : AreaManager.Area - Area that signaled a client has entered. - client : ClientManager.Client, optional - The client that has send the IC message. The default is None. - contents : dict of str to Any - Arguments of the IC message as indicated in AOProtocol. + user : ClientManager.Client + User whose games with areas will be returned. Returns ------- - None. + Set[_GameWithAreas] + Games with areas the player belongs to. """ - # print('User', client, 'in area', area, 'wants to check sent', contents) + return super().get_managees_of_user(user) - self._check_structure() + def get_player_to_managees_map(self) -> Dict[ClientManager.Client, Set[_GameWithAreas]]: + """ + Return a mapping of the players part of any game with areas managed by this manager to the + game with areas managed by this manager such players belong to. - def _on_area_destroyed(self, area: AreaManager.Area): + Returns + ------- + Dict[ClientManager.Client, Set[_GameWithAreas]] + Mapping. """ - Default callback for game area signaling it was destroyed. - By default it calls self.remove_area(area). + return super().get_player_to_managees_map() - Parameters - ---------- - area : AreaManager.Area - Area that signaled it was destroyed. + def get_users_in_some_managee(self) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) all the users that are part of some game with areas managed by + this manager. Returns ------- - None. + Set[ClientManager.Client] + Users in some managed game with areas. """ - # print('Received DESTRUCTION', area) - self.remove_area(area) - - self._check_structure() + return super().get_users_in_some_managee() - def _on_areas_loaded(self, area_manager: AreaManager): + def is_managee_creatable(self) -> bool: """ - Default callback for server area manager signaling it loaded new areas. - - By default it calls self.destroy(). - - Parameters - ---------- - area_manager : AreaManager - AreaManager that signaled the area loads + Return whether a new game with areas can currently be created without creating one. Returns ------- - None. + bool + True if a game with areas can be currently created, False otherwise. + """ + + return super().is_managee_creatable() + def get_id(self) -> str: """ + Return the ID of this manager. This ID is guaranteed to be unique among + simultaneously existing Python objects. - self.destroy() + Returns + ------- + str + ID. - def _check_structure(self): """ - Assert that all invariants specified in the class description are maintained. - Raises - ------ - AssertionError - If any of the invariants are not maintained. + return super().get_id() + def find_player_concurrent_limiting_managee( + self, + user: ClientManager.Client + ) -> Union[_GameWithAreas, None]: """ + For user `user`, find a game with areas `most_restrictive_game` managed by this manager such + that, if `user` were to join another game with areas managed by this manager, they would + violate `most_restrictive_game`'s concurrent player membership limit. + If no such game with areas exists (or the player is not member of any game with areas + managed by this manager), return None. + If multiple such games with areas exist, any one of them may be returned. - # 1. - for player in self.get_players(): - err = (f'For game with areas {self}, expected that its player {player} was in an area ' - f'part of the game, found they were in area {player.area} instead.') - assert player.area in self._areas, err + Parameters + ---------- + user : ClientManager.Client + User to test. - # 2. - if self._autoadd_on_client_enter and self.requires_invitations(): - err = (f'For game with areas {self}, expected that it did not simultaneously require ' - f'invitations for users to join while mandating users be automatically added ' - f'if they enter an area of the game, found it did.') - raise AssertionError(err) + Returns + ------- + Union[_GameWithAreas, None] + Limiting game with areas as previously described if it exists, None otherwise. - # 2. - super()._check_structure() + """ + return super().find_player_concurrent_limiting_managee(user) -class GameWithAreasManager(GameManager): +class GameWithAreasManager(_GameWithAreasManagerTrivialInherited): """ A game with areas manager is a game manager with dedicated area management functions. + Attributes + ---------- + server : TsuserverDR + Server the game with areas manager belongs to. """ - # TODO: Enforce GameWithAreasManager to only take game with areas as games when calling - # new_game, or when initialized. Also do it in check_structure() - - def __init__(self, server, game_limit=None, default_game_type=None, - available_id_producer=None): + # Invariants + # ---------- + # 1. For every area and game with areas pair (`area`, `games`) in + # `self.get_areas_to_managees_map().items()`: + # a. For every game with areas `game` in `games`: + # 1. `game` has no area concurrent membership limit, or it is at least the length + # of `games`. + # 2. The invariants of the parent class are maintained. + + def __init__( + self, + server: TsuserverDR, + managee_limit: Union[int, None] = None, + default_managee_type: Type[_GameWithAreas] = None, + ): """ Create a game with areas manager object. @@ -701,164 +2683,240 @@ def __init__(self, server, game_limit=None, default_game_type=None, ---------- server : TsuserverDR The server this game with areas manager belongs to. - game_limit : int, optional - The maximum number of games this manager can handle. Defaults to None (no limit). - default_game_type : GameWithAreas, optional - The default type of game this manager will create. Defaults to None (and then - converted to GameWithAreas). - available_id_producer : typing.types.FunctionType, optional - Function to produce available game IDs. It will override the built-in class method - get_available_game_id. Defaults to None (and then converted to the built-in - get_available_game_id). + managee_limit : int, optional + The maximum number of games with areas this manager can handle. Defaults to None + (no limit). + default_managee_type : Type[_GameWithAreas], optional + The default type of game with areas this manager will create. Defaults to None (and then + converted to _GameWithAreas). """ - if default_game_type is None: - default_game_type = GameWithAreas - self._area_to_games = dict() + if default_managee_type is None: + default_managee_type = _GameWithAreas + + super().__init__( + server, + managee_limit=managee_limit, + default_managee_type=default_managee_type + ) + + def unchecked_new_managee( + self, + managee_type: Type[_GameWithAreas] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + areas: Set[AreaManager.Area] = None, + area_concurrent_limit: Union[int, None] = None, + autoadd_on_client_enter: bool = False, + autoadd_on_creation_existing_users: bool = False, + require_areas: bool = True, + **kwargs: Any, + ) -> _GameWithAreas: - super().__init__(server, game_limit=game_limit, default_game_type=default_game_type, - available_id_producer=available_id_producer) - - def new_game(self, game_type=None, creator=None, player_limit=None, - player_concurrent_limit=1, require_invitations=False, require_players=True, - require_leaders=True, require_character=False, team_limit=None, timer_limit=None, - areas=None, area_concurrent_limit=None, - autoadd_on_client_enter=False) -> GameWithAreas: """ Create a new game with areas managed by this manager. + This method does not assert structural integrity. + Parameters ---------- - game_type : _Game or functools.partial - Class of game that will be produced. Defaults to None (and converted to the default - game created by this game manager). - creator : ClientManager.Client, optional - The player who created this game. If set, they will also be added to the game. - Defaults to None. - player_limit : int or None, optional - If an int, it is the maximum number of players the game supports. If None, it - indicates the game has no player limit. Defaults to None. - player_concurrent_limit : int or None, optional - If an int, it is the maximum number of games managed by `self` that any player - of this game to create may belong to, including this game to create. If None, it - indicates that this game does not care about how many other games managed by `self` - each of its players belongs to. Defaults to 1 (a player may not be in another game - managed by `self` while in this game). + managee_type : Type[_GameWithAreas], optional + Class of game with areas that will be produced. Defaults to None (and converted to the + default game with areas created by this game with areas manager). + creator : Union[ClientManager.Client, None], optional + The player who created this game with areas. If set, they will also be added to the + game with areas. Defaults to None. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the game with areas supports. If None, it + indicates the game with areas has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of games with areas managed by `self` that any + player of this game with areas to create may belong to, including this game with areas + to create. If None, it indicates that this game with areas does not care about how many + other games with areas managed by `self` each of its players belongs to. Defaults to 1 + (a player may not be in another game managed by `self` while in this game). require_invitations : bool, optional - If True, users can only be added to the game if they were previously invited. If - False, no checking for invitations is performed. Defaults to False. + If True, users can only be added to the game with areas if they were previously invited. + If False, no checking for invitations is performed. Defaults to False. require_players : bool, optional - If True, if at any point the game loses all its players, the game will automatically - be deleted. If False, no such automatic deletion will happen. Defaults to True. + If True, if at any point the game with areas loses all its players, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. require_leaders : bool, optional - If True, if at any point the game has no leaders left, the game will choose a leader - among any remaining players left; if no players are left, the next player added will - be made leader. If False, no such automatic assignment will happen. Defaults to True. - require_character : bool, optional - If False, players without a character will not be allowed to join the game, and players - that switch to something other than a character will be automatically removed from the - game. If False, no such checks are made. A player without a character is considered - one where player.has_character() returns False. Defaults to False. - team_limit : int or None, optional - If an int, it is the maximum number of teams the game will support. If None, it - indicates the game will have no team limit. Defaults to None. - timer_limit : int or None, optional - If an int, it is the maximum number of timers the game will support. If None, it - indicates the game will have no timer limit. Defaults to None. - areas : set of AreaManager.Area, optional - Areas the game starts with. Defaults to None (and converted to an empty set). - area_concurrent_limit : int or None, optional - If an int, it is the maximum number of games with areas managed by `manager` that any - area of this game may belong to, including this game. If None, it indicates - that this game does not care about how many other game with areas managed by - `manager` each of its areas belongs to. Defaults to 1 (an area may not be a part of - another game managed by `manager` while being an area of this game). - autoadd_on_client_enter : bool, optional - If True, nonplayer users that enter an area part of the game will be automatically - added if permitted by the conditions of the game. If False, no such adding will take - place. Defaults to False. + If True, if at any point the game with areas has no leaders left, the game with areas + will choose a leader among any remaining players left; if no players are left, the next + player added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + game with areas, and players that switch to something other than a participant character + will be automatically removed from the game with areas. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the game with areas will support. If None, + it indicates the game with areas will have no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the game with areas will support. If None, + it indicates the game with areas will have no timer limit. Defaults to None. + areas : Set[AreaManager.Area], optional + The areas to add to the game with areas when creating it. Defaults to None (and + converted to a set containing the creator's area if given a creator, and the empty set + otherwise). + area_concurrent_limit : Union[int, None] + The concurrent area membership limit of this game with areas. Defaults to None. + autoadd_on_client_enter: bool + If the game with areas will always attempt to add nonplayer users who enter an area + part of the game with areas. Defaults to False. + autoadd_on_creation_existing_users : bool + If the game with areas will attempt to add nonplayer users who were in an area added + to the game with areas on creation. Defaults to False. + require_areas : bool, optional + If True, if at any point the game with areas has no areas left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + **kwargs : Any + Additional arguments to consider when producing the game with areas. Returns ------- - GameWithAreas - The created game. + _GameWithAreas + The created game with areas. Raises ------ - GameError.ManagerTooManyGamesError + GameWithAreasError.ManagerTooManyGamesError If the manager is already managing its maximum number of games. - Any error from the created game's add_player(creator) - If the game cannot add `creator` as a player if given one. + Any error from the created game with areas's add_player(creator) + If the game with areas cannot add `creator` as a player if given one. """ - if game_type is None: - game_type = self.get_default_game_type() + if managee_type is None: + managee_type = self.get_managee_type() + if not areas: + areas = {creator.area} if creator else set() - new_game_type = functools.partial(game_type, - area_concurrent_limit=area_concurrent_limit, - autoadd_on_client_enter=autoadd_on_client_enter) - - game = super().new_game(game_type=new_game_type, creator=None, player_limit=player_limit, - player_concurrent_limit=player_concurrent_limit, - require_invitations=require_invitations, - require_players=require_players, - require_leaders=require_leaders, - require_character=require_character, - team_limit=team_limit, - timer_limit=timer_limit) + try: + game: _GameWithAreas = super().unchecked_new_managee( + managee_type=managee_type, + creator=None, # Manually none + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + # kwargs + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + require_areas=require_areas, + **kwargs, + ) + except GameError.ManagerTooManyGamesError: + raise GameWithAreasError.ManagerTooManyGamesError try: for area in areas: - game.add_area(area) - except GameError as ex: + game.unchecked_add_area(area) + except GameWithAreasError as ex: # Discard game - self.delete_game(game) + self.unchecked_delete_managee(game) raise ex # Add creator manually. This is because adding it via .new_game will yield errors because # the areas are not added until the section before. try: if creator: - game.add_player(creator) - except GameError as ex: + game.unchecked_add_player(creator) + except GameWithAreasError as ex: # Discard game - self.delete_game(game) + self.unchecked_delete_managee(game) raise ex + if autoadd_on_creation_existing_users: + clients_to_add = {client for area in areas for client in area.clients} + if creator: + clients_to_add.discard(creator) + for client in clients_to_add: + try: + game.add_player(client) + except GameWithAreasError as ex: + # Discard game + self.unchecked_delete_managee(game) + raise ex + return game - def get_games_in_area(self, area) -> Set[GameWithAreas]: + def get_available_managee_id(self): + """ + Get a game with areas ID that no other game with areas managed by this manager has. + + Returns + ------- + str + A unique game with areas ID. + + Raises + ------ + GameWithAreasError.ManagerTooManyGamesError + If the manager is already managing its maximum number of games. + """ - Return (a shallow copy of) the all games managed by this manager that contain the given - area. + + game_number = 0 + game_limit = self.get_managee_limit() + while game_limit is None or game_number < game_limit: + new_game_id = f'gwa{game_number}' + if new_game_id not in self.get_managee_ids(): + return new_game_id + game_number += 1 + raise GameWithAreasError.ManagerTooManyGamesError + + def get_managees_in_area(self, area: AreaManager.Area) -> Set[_GameWithAreas]: + """ + Return (a shallow copy of) all games with areas managed by this manager that contain + the given area. Parameters ---------- area : AreaManager.Area - Area that all returned games must contain. + Area that all returned games with areas must contain. Returns ------- - set of GameWithAreas - Games that contain the given area. + Set[_GameWithAreas] + Games with areas that contain the given area. """ + areas_to_managees = self.get_areas_to_managees_map() try: - return self._area_to_games[area].copy() + return areas_to_managees[area].copy() except KeyError: return set() - def _find_area_concurrent_limiting_game(self, area: AreaManager.Area): + def find_area_concurrent_limiting_managee( + self, + area: AreaManager.Area + ) -> Union[_GameWithAreas, None]: """ For area `area`, find a game with areas `most_restrictive_game` managed by this manager - such that, if `area` were to be added to another game managed by this manager, they would - violate `most_restrictive_game`'s concurrent area membership limit. - If no such game exists (or the area is not an area of any game with areas managed by this - manager), return None. - If multiple such games exist, any one of them may be returned. + such that, if `area` were to be added to another game with areas managed by this manager, + they would violate `most_restrictive_game`'s concurrent area membership limit. + If no such game with areas exists (or the area is not an area of any game with areas + managed by this manager), return None. + If multiple such games with areas exist, any one of them may be returned. Parameters ---------- @@ -867,91 +2925,50 @@ def _find_area_concurrent_limiting_game(self, area: AreaManager.Area): Returns ------- - GameWithAreas or None + Union[_GameWithAreas, None] Limiting game with areas as previously described if it exists, None otherwise. """ - games = self.get_games_in_area(area) + games = self.get_managees_in_area(area) if not games: return None - # We only care about groups that establish a concurrent area membership limit + # We only care about games that establish a concurrent area membership limit games_with_limit = {game for game in games if game.get_area_concurrent_limit() is not None} if not games_with_limit: return None # It just suffices to analyze the game with the smallest limit, because: - # 1. If the area is part of at least as many games as this game's limit, this game - # is an example game that can be returned. - # 2. Otherwise, no other games exist due to the minimality condition. - most_restrictive_game = min(games_with_limit, - key=lambda game: game.get_area_concurrent_limit()) + # 1. If the area is part of at least as many games with areas as this game with areas's + # limit, this game with areas is an example game with areas that can be returned. + # 2. Otherwise, no other games with areas exist due to the minimality condition. + most_restrictive_game: _GameWithAreas = min( + games_with_limit, key=lambda game: game.get_area_concurrent_limit()) if len(games) < most_restrictive_game.get_area_concurrent_limit(): return None return most_restrictive_game - def _add_area_to_mapping(self, area: AreaManager.Area, game: GameWithAreas): - """ - Update the area to game with areas mapping with the information that `area` was added to - `game`. - - Parameters - ---------- - area : AreaManager.Area - Area that was added. - game : GameWithAreas - Game with areas that `area` was added to. - - Raises - ------ - GameWithAreasError.AreaHitGameConcurrentLimitError. - If `area` has reached the concurrent area membership limit of any of the games it - belongs to managed by this manager, or by virtue of adding this area to `game` it - will violate this game's concurrent area membership limit. - - Returns - ------- - None. - - """ - - if self._find_area_concurrent_limiting_game(area): - raise GameWithAreasError.AreaHitGameConcurrentLimitError - - try: - self._area_to_games[area].add(game) - except KeyError: - self._area_to_games[area] = {game} - - def _remove_area_from_mapping(self, area: AreaManager.Area, game: GameWithAreas): + def get_areas_to_managees_map(self) -> Dict[ClientManager.Client, Set[_GameWithAreas]]: """ - Update the area to game with areas mapping with the information that `area` was removed - from `game`. - If the area is already not associated with that game, or is not part of the mapping, - this method will not do anything. - - Parameters - ---------- - area : AreaManager.Area - Area that was removed. - game : GameWithAreas - Game with areas that `area` was removed from. + Return a mapping of the areas part of any game with areas managed by this manager to the + game with areas managed by this manager such players belong to. Returns ------- - None. - + Dict[ClientManager.Client, Set[_GameWithAreas]] + Mapping. """ - try: - self._area_to_games[area].remove(game) - except (KeyError, ValueError): - return + output = dict() + for group in self.get_managees(): + for area in group.get_areas(): + if area not in output: + output[area] = set() + output[area].add(group) - if not self._area_to_games[area]: - self._area_to_games.pop(area) + return output def _check_structure(self): """ @@ -965,33 +2982,8 @@ def _check_structure(self): """ # 1. - for area in self._area_to_games: - games = self._area_to_games[area] - - # a. - err = (f'For game with areas manager {self}, expected that area {area} to only appear ' - f'in the area to game with areas mapping if it was a area of any area ' - f'game managed by this manager, but found it appeared while not belonging to ' - f'any game with areas. || {self}') - assert games, err - - for game in games: - # b. - err = (f'For game with areas manager {self}, expected that game with areas {game} ' - f'that appears in the area to game with areas mapping for area {area} ' - f'also appears in the game with areas ID to game with areas mapping, but ' - f'found it did not. || {self}') - assert game in self.get_games(), err - - # c. - err = (f'For game with areas manager {self}, expected that area {area} in the area ' - f'to game mapping be a area of its associated game {game}, but ' - f'found that was not the case. || {self}') - assert area in game.get_areas(), err - - # 2. - for area in self._area_to_games: - games = self._area_to_games[area] + area_to_games = self.get_areas_to_managees_map() + for (area, games) in area_to_games.items(): membership = len(games) for game in games: @@ -999,11 +2991,13 @@ def _check_structure(self): if limit is None: continue - err = (f'For game with areas manager {self}, expected that area {area} in game ' - f'{game} belonged to at most the concurrent area membership limit of ' - f'that game of {limit} game{"s" if limit != 1 else ""}, found it ' - f'belonged to {membership} game{"s" if membership != 1 else ""}. || {self}') - assert membership <= limit + assert membership <= limit, ( + f'For game with areas manager {self}, expected that area {area} in game with ' + f'areas {game} belonged to at most the concurrent area membership limit of ' + f'that game with areas of {limit} game {"s" if limit != 1 else ""} with areas, ' + f'found it belonged to {membership} game{"s" if membership != 1 else ""} with ' + f'areas. || {self}' + ) # Last super()._check_structure() @@ -1019,7 +3013,11 @@ def __repr__(self) -> str: """ - return (f"GameWithAreasManager(server, game_limit={self.get_game_limit()}, " + return (f"GameWithAreasManager(server, managee_limit={self.get_managee_limit()}, " + f"default_managee_type={self.get_managee_type()}, " f"|| " - f"_area_to_games={self._area_to_games}, " - f"id={hex(id(self))})") + f"_id_to_managee={self.get_managee_ids_to_managees()}, " + f"_user_to_managees={self.get_player_to_managees_map()}, " + f"_area_to_managees={self.get_areas_to_managees_map()}, " + f"id={self.get_id()}, " + f')') diff --git a/server/hub_manager.py b/server/hub_manager.py new file mode 100644 index 000000000..fb681d283 --- /dev/null +++ b/server/hub_manager.py @@ -0,0 +1,3159 @@ +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. +# +# Copyright (C) 2016 argoneus (original tsuserver3) +# (C) 2018-22 Chrezm/Iuvee (further additions) +# +# 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 3 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, see . + +""" +Module that contains the hub manager and hub modules. + +""" + +from __future__ import annotations + +import secrets +import typing + +from server.area_manager import AreaManager +from server.background_manager import BackgroundManager +from server.character_manager import CharacterManager +from server.constants import Constants +from server.exceptions import AreaError, HubError, GameWithAreasError, ServerError +from server.gamewithareas_manager import _GameWithAreas, GameWithAreasManager +from server.music_manager import MusicManager + +from typing import Callable, Dict, List, Set, Any, Tuple, Type, Union + +from server.trial_manager import TrialManager +from server.zone_manager import ZoneManager + +if typing.TYPE_CHECKING: + from server.client_manager import ClientManager + from server.game_manager import _Team + from server.timer_manager import Timer + from server.tsuserver import TsuserverDR + +class _HubTrivialInherited(_GameWithAreas): + """ + This class should not be instantiated. + """ + + def get_id(self) -> str: + """ + Return the ID of this hub. + + Returns + ------- + str + The ID. + + """ + + return super().get_id() + + def get_numerical_id(self) -> int: + """ + Return the numerical portion of the ID of this hub. + + Returns + ------- + int + Numerical portion of the ID. + """ + + return super().get_numerical_id() + + def get_name(self) -> str: + """ + Get the name of the hub. + + Returns + ------- + str + Name. + """ + + return super().get_name() + + def set_name(self, name: str): + """ + Set the name of the hub. + + Parameters + ---------- + name : str + Name. + """ + + self.unchecked_set_name(name) + self.manager._check_structure() + + def unchecked_set_name(self, name: str): + """ + Set the name of the hub. + + This method does not assert structural integrity. + + Parameters + ---------- + name : str + Name. + """ + + super().unchecked_set_name(name) + + def get_player_limit(self) -> Union[int, None]: + """ + Return the player membership limit of this hub. + + Returns + ------- + Union[int, None] + The player membership limit. + + """ + + return super().get_player_limit() + + def get_player_concurrent_limit(self) -> Union[int, None]: + """ + Return the concurrent player membership limit of this hub. + + Returns + ------- + Union[int, None] + The concurrent player membership limit. + + """ + + return super().get_player_concurrent_limit() + + def get_players( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of players of this hub that satisfy a + condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all players returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) players of this hub. + + """ + + return super().get_players(cond=cond) + + def is_player(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is a player of the hub. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Returns + ------- + bool + True if the user is a player, False otherwise. + + """ + + return super().is_player(user) + + def add_player(self, user: ClientManager.Client): + """ + Make a user a player of the hub. By default this player will not be a leader, + unless the hub has no leaders and it requires a leader. + It will also subscribe the hub to the player so it can listen to its updates. + + Newly added players will be ordered to switch to a 'hub' variant. + + Parameters + ---------- + user : ClientManager.Client + User to add to the hub. They must be in an area part of the hub. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.UserNotInAreaError + If the user is not in an area part of the hub. + HubError.UserHasNoCharacterError + If the user has no character but the hub requires that all players have + characters. + HubError.UserNotInvitedError + If the hub requires players be invited to be added and the user is not + invited. + HubError.UserAlreadyPlayerError + If the user to add is already a user of the hub. + HubError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the hub + managed by the manager of this hub, or by virtue of joining this + hub they would violate this hub's concurrent player membership limit. + HubError.GameIsFullError + If the hub reached its player limit. + + """ + + self.unchecked_add_player(user) + self.manager._check_structure() + + def unchecked_add_player(self, user: ClientManager.Client): + """ + Make a user a player of the hub. By default this player will not be a leader, + unless the hub has no leaders and it requires a leader. + It will also subscribe the hub to the player so it can listen to its updates. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to add to the hub. They must be in an area part of the hub. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.UserNotInAreaError + If the user is not in an area part of the hub. + HubError.UserHasNoCharacterError + If the user has no character but the hub requires that all players have + characters. + HubError.UserNotInvitedError + If the hub requires players be invited to be added and the user is not + invited. + HubError.UserAlreadyPlayerError + If the user to add is already a user of the hub. + HubError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the hub + managed by the manager of this hub, or by virtue of joining this + hub they would violate this hub's concurrent player membership + limit. + HubError.GameIsFullError + If the hub reached its player limit. + + """ + + try: + super().unchecked_add_player(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.UserHasNoCharacterError: + raise HubError.UserHasNoCharacterError + except GameWithAreasError.UserNotInvitedError: + raise HubError.UserNotInvitedError + except GameWithAreasError.UserAlreadyPlayerError: + raise HubError.UserAlreadyPlayerError + except GameWithAreasError.UserHitGameConcurrentLimitError: + raise HubError.UserHitGameConcurrentLimitError + except GameWithAreasError.GameIsFullError: + raise HubError.GameIsFullError + + def remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this hub. If they were part of a team + managed by this hub, they will also be removed from said team. It will also + unsubscribe the hub from the player so it will no longer listen to its updates. + + If the hub required that there it always had players and by calling this method + the hub had no more players, the hub will automatically be scheduled + for deletion. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.UserNotPlayerError + If the user to remove is already not a player of this hub. + + """ + + self.unchecked_remove_player(user) + self.manager._check_structure() + + def unchecked_remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this hub. If they were part of a team + managed by this hub, they will also be removed from said team. It will also + unsubscribe the hub from the player so it will no longer listen to its updates. + + If the hub required that there it always had players and by calling this method + the hub had no more players, the hub will automatically be scheduled + for deletion. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.UserNotPlayerError + If the user to remove is already not a player of this hub. + + """ + try: + super().unchecked_remove_player(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.UserNotPlayerError: + raise HubError.UserNotPlayerError + + def requires_players(self) -> bool: + """ + Return whether the hub requires players at all times. + + Returns + ------- + bool + Whether the hub requires players at all times. + """ + + return super().requires_players() + + def get_invitations( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of invited users of this hub that satisfy a + condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all invited users returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) invited users of this hub. + + """ + + return super().get_invitations(cond=cond) + + def is_invited(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is invited to the hub. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Raises + ------ + HubError.UserAlreadyPlayerError + If the user is a player of this hub. + + Returns + ------- + bool + True if the user is invited, False otherwise. + + """ + + try: + return super().is_invited(user) + except GameWithAreasError.UserAlreadyPlayerError: + raise HubError.UserAlreadyPlayerError + + def add_invitation(self, user: ClientManager.Client): + """ + Mark a user as invited to this hub. + + Parameters + ---------- + user : ClientManager.Client + User to invite to the hub. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameDoesNotTakeInvitationsError + If the hub does not require users be invited to the hub. + HubError.UserAlreadyInvitedError + If the player to invite is already invited to the hub. + HubError.UserAlreadyPlayerError + If the player to invite is already a player of the hub. + + """ + + self.unchecked_add_invitation(user) + self.manager._check_structure() + + def unchecked_add_invitation(self, user: ClientManager.Client): + """ + Mark a user as invited to this hub. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to invite to the hub. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameDoesNotTakeInvitationsError + If the hub does not require users be invited to the hub. + HubError.UserAlreadyInvitedError + If the player to invite is already invited to the hub. + HubError.UserAlreadyPlayerError + If the player to invite is already a player of the hub. + + """ + + try: + super().unchecked_add_invitation(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.GameDoesNotTakeInvitationsError: + raise HubError.GameDoesNotTakeInvitationsError + except GameWithAreasError.UserAlreadyInvitedError: + raise HubError.UserAlreadyInvitedError + except GameWithAreasError.UserAlreadyPlayerError: + raise HubError.UserAlreadyPlayerError + + def remove_invitation(self, user: ClientManager.Client): + """ + Mark a user as no longer invited to this hub (uninvite). + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameDoesNotTakeInvitationsError + If the hub does not require users be invited to the hub. + HubError.UserNotInvitedError + If the user to uninvite is already not invited to this hub. + + """ + + self.unchecked_remove_invitation(user) + self.manager._check_structure() + + def unchecked_remove_invitation(self, user: ClientManager.Client): + """ + Mark a user as no longer invited to this hub (uninvite). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameDoesNotTakeInvitationsError + If the hub does not require users be invited to the hub. + HubError.UserNotInvitedError + If the user to uninvite is already not invited to this hub. + + """ + + try: + super().unchecked_remove_invitation(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.GameDoesNotTakeInvitationsError: + raise HubError.GameDoesNotTakeInvitationsError + except GameWithAreasError.UserNotInvitedError: + raise HubError.UserNotInvitedError + + def requires_invitations(self): + """ + Return True if the hub requires players be invited before being allowed to join + the hub, False otherwise. + + Returns + ------- + bool + True if the hub requires players be invited before being allowed to join + the hub, False otherwise. + """ + + return super().requires_invitations() + + def get_leaders( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of leaders of this hub that satisfy a condition + if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all leaders returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) leaders of this hub. + + """ + + return super().get_leaders(cond=cond) + + def get_regulars( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of players of this hub that are regulars and + satisfy a condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all regulars returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) regulars of this hub. + + """ + + return super().get_regulars(cond=cond) + + def is_leader(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is a leader of the hub. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Raises + ------ + HubError.UserNotPlayerError + If the player to test is not a player of this hub. + + Returns + ------- + bool + True if the player is a user, False otherwise. + + """ + + try: + return super().is_leader(user) + except GameWithAreasError.UserNotPlayerError: + raise HubError.UserNotPlayerError + + def add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this hub (promote to leader). + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.UserNotPlayerError + If the player to promote is not a player of this hub. + HubError.UserAlreadyLeaderError + If the player to promote is already a leader of this hub. + + """ + + self.unchecked_add_leader(user) + self.manager._check_structure() + + def unchecked_add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this hub (promote to leader). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.UserNotPlayerError + If the player to promote is not a player of this hub. + HubError.UserAlreadyLeaderError + If the player to promote is already a leader of this hub. + + """ + + try: + super().unchecked_add_leader(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.UserNotPlayerError: + raise HubError.UserNotPlayerError + except GameWithAreasError.UserAlreadyLeaderError: + raise HubError.UserAlreadyLeaderError + + def remove_leader(self, user: ClientManager.Client): + """ + Make a user no longer leader of this hub (demote). + + Parameters + ---------- + user : ClientManager.Client + User to demote. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.UserNotPlayerError + If the player to demote is not a player of this hub. + HubError.UserNotLeaderError + If the player to demote is already not a leader of this hub. + + """ + + self.unchecked_remove_leader(user) + self.manager._check_structure() + + def unchecked_remove_leader(self, user: ClientManager.Client): + """ + Make a user no longer leader of this hub (demote). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to demote. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.UserNotPlayerError + If the player to demote is not a player of this hub. + HubError.UserNotLeaderError + If the player to demote is already not a leader of this hub. + + """ + + if self.is_unmanaged(): + raise HubError.GameIsUnmanagedError + + try: + super().unchecked_remove_leader(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.UserNotPlayerError: + raise HubError.UserNotPlayerError + except GameWithAreasError.UserNotLeaderError: + raise HubError.UserNotLeaderError + + def has_ever_had_players(self) -> bool: + """ + Return True if a player has ever been added to this hub, False otherwise. + + Returns + ------- + bool + True if the hub has ever had a player added, False otherwise. + + """ + + return super().has_ever_had_players() + + def requires_leaders(self) -> bool: + """ + Return whether the hub requires leaders at all times. + + Returns + ------- + bool + Whether the hub requires leaders at all times. + """ + + return super().requires_leaders() + + def requires_participant_characters(self) -> bool: + """ + Return whether the hub requires players have a participant character at all times. + + Returns + ------- + bool + Whether the hub requires players have a participant character at all times. + """ + + return super().requires_participant_characters() + + def new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this hub with given parameters. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the hub will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameTooManyTimersError + If the hub is already managing its maximum number of timers. + + """ + + timer = self.unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + self.manager._check_structure() + return timer + + def unchecked_new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this hub with given parameters. + + This method does not assert structural integrity. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the hub will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameTooManyTimersError + If the hub is already managing its maximum number of timers. + + """ + + try: + return super().unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.GameTooManyTimersError: + raise HubError.GameTooManyTimersError + + def delete_timer(self, timer: Timer) -> str: + """ + Delete a timer managed by this hub, terminating it first if needed. + + Parameters + ---------- + timer : Timer + The timer to delete. + + Returns + ------- + str + The ID of the timer that was deleted. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameDoesNotManageTimerError + If the hub does not manage the target timer. + + """ + + timer_id = self.unchecked_delete_timer(timer) + self.manager._check_structure() + return timer_id + + def unchecked_delete_timer(self, timer: Timer) -> str: + """ + Delete a timer managed by this hub, terminating it first if needed. + + This method does not assert structural integrity. + + Parameters + ---------- + timer : Timer + The timer to delete. + + Returns + ------- + str + The ID of the timer that was deleted. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameDoesNotManageTimerError + If the hub does not manage the target timer. + + """ + + try: + return super().unchecked_delete_timer(timer) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.GameDoesNotManageTimerError: + raise HubError.GameDoesNotManageTimerError + + def get_timers(self) -> Set[Timer]: + """ + Return (a shallow copy of) the timers this hub manages. + + Returns + ------- + Set[Timer] + Timers this hub manages. + + """ + + return super().get_timers() + + def get_timer_by_id(self, timer_id: str) -> Timer: + """ + If `timer_tag` is the ID of a timer managed by this hub, return that timer. + + Parameters + ---------- + timer_id: str + ID of timer this hub manages. + + Returns + ------- + Timer + The timer whose ID matches the given ID. + + Raises + ------ + HubError.GameInvalidTimerIDError: + If `timer_tag` is a str and it is not the ID of a timer this hub manages. + + """ + + try: + return super().get_timer_by_id(timer_id) + except GameWithAreasError.GameInvalidTimerIDError: + raise HubError.GameInvalidTimerIDError + + def get_timer_limit(self) -> Union[int, None]: + """ + Return the timer limit of this hub. + + Returns + ------- + Union[int, None] + Timer limit. + + """ + + return super().get_timer_limit() + + def get_timer_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all timers managed by this hub. + + Returns + ------- + Set[str] + The IDs of all managed timers. + + """ + + return super().get_timer_ids() + + def new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this hub. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this hub. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + + Returns + ------- + _Team + The created team. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameTooManyTeamsError + If the hub is already managing its maximum number of teams. + HubError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this hub. + + """ + + team = self.unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + self.manager._check_structure() + return team + + def unchecked_new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this hub. + + This method does not assert structural integrity. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this hub. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + + Returns + ------- + _Team + The created team. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameTooManyTeamsError + If the hub is already managing its maximum number of teams. + HubError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this hub. + + """ + + try: + return super().unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.GameTooManyTeamsError: + raise HubError.GameTooManyTeamsError + except GameWithAreasError.UserInAnotherTeamError: + raise HubError.UserInAnotherTeamError + + def delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this hub. + + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameDoesNotManageTeamError + If the hub does not manage the target team. + + """ + + team_id, players = self.unchecked_delete_team(team) + self.manager._check_structure() + return team_id, players + + def unchecked_delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this hub. + + This method does not assert structural integrity. + + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.GameDoesNotManageTeamError + If the hub does not manage the target team. + + """ + + try: + return super().unchecked_delete_team(team) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.GameDoesNotManageTeamError: + raise HubError.GameDoesNotManageTeamError + + def manages_team(self, team: _Team) -> bool: + """ + Return True if the team is managed by this hub, False otherwise. + + Parameters + ---------- + team : _Team + The team to check. + + Returns + ------- + bool + True if the hub manages this team, False otherwise. + + """ + + return super().manages_team(team) + + def get_teams(self) -> Set[_Team]: + """ + Return (a shallow copy of) the teams this hub manages. + + Returns + ------- + Set[_Team] + Teams this hub manages. + + """ + + return super().get_teams() + + def get_team_by_id(self, team_id: str) -> _Team: + """ + If `team_id` is the ID of a team managed by this hub, return the team. + + Parameters + ---------- + team_id : str + ID of the team this hub manages. + + Returns + ------- + _Team + The team that matches the given ID. + + Raises + ------ + HubError.GameInvalidTeamIDError: + If `team_id` is not the ID of a team this hub manages. + + """ + + try: + return super().get_team_by_id(team_id) + except GameWithAreasError.GameInvalidTeamIDError: + raise HubError.GameInvalidTeamIDError + + def get_team_limit(self) -> Union[int, None]: + """ + Return the team limit of this hub. + + Returns + ------- + Union[int, None] + Team limit. + + """ + + return super().get_team_limit() + + def get_team_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all teams managed by this hub. + + Returns + ------- + Set[str] + The IDs of all managed teams. + + """ + + return super().get_team_ids() + + def get_teams_of_user(self, user: ClientManager.Client) -> Set[_Team]: + """ + Return (a shallow copy of) the teams managed by this hub user `user` is a player + of. If the user is part of no such team, an empty set is returned. + + Parameters + ---------- + user : ClientManager.Client + User whose teams will be returned. + + Returns + ------- + Set[_Team] + Teams the player belongs to. + + """ + + return super().get_teams_of_user(user) + + def get_users_in_some_team(self): + """ + Return (a shallow copy of) all the users that are part of some team managed by this + hub. + + Returns + ------- + Set[ClientManager.Client] + Users in some managed team. + + """ + + return super().get_users_in_some_team() + + def get_available_team_id(self) -> str: + """ + Get a team ID that no other team managed by this team has. + + Returns + ------- + str + A unique team ID. + + Raises + ------ + HubError.GameTooManyTeamsError + If the hub is already managing its maximum number of teams. + + """ + + try: + return super().get_available_team_id() + except GameWithAreasError.GameTooManyTeamsError: + raise HubError.GameTooManyTeamsError + + def get_autoadd_on_client_enter(self) -> bool: + """ + Return True if the hub will always attempt to add nonplayer users who enter an + area part of the hub, False otherwise. + + Returns + ------- + bool + True if the hub will always attempt to add nonplayer users who enter an area + part of the hub, False otherwise. + """ + + return super().get_autoadd_on_client_enter() + + def set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + Parameters + ---------- + new_value : bool + New value. + + """ + + self.unchecked_set_autoadd_on_client_enter(new_value) + self.manager._check_structure() + + def unchecked_set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + This method does not assert structural integrity. + + Parameters + ---------- + new_value : bool + New value. + + """ + + super().unchecked_set_autoadd_on_client_enter(new_value) + + def add_area(self, area: AreaManager.Area): + """ + Add an area to this hub's set of areas. + + Parameters + ---------- + area : AreaManager.Area + Area to add. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.AreaAlreadyInGameError + If the area is already part of the hub. + HubError.AreaHitGameConcurrentLimitError. + If `area` has reached the concurrent area membership limit of any of the games with + areas it belongs to managed by this manager, or by virtue of adding this area it will + violate this hub's concurrent area membership limit. + + """ + + self.unchecked_add_area(area) + self.manager._check_structure() + + def unchecked_add_area(self, area: AreaManager.Area): + """ + Add an area to this hub's set of areas. + + This method does not assert structural integrity. + + Parameters + ---------- + area : AreaManager.Area + Area to add. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.AreaAlreadyInGameError + If the area is already part of the hub. + HubError.AreaHitGameConcurrentLimitError. + If `area` has reached the concurrent area membership limit of any of the games with + areas it belongs to managed by this manager, or by virtue of adding this area it will + violate this hub's concurrent area membership limit. + + """ + + try: + super().unchecked_add_area(area) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.AreaAlreadyInGameError: + raise HubError.AreaAlreadyInGameError + except GameWithAreasError.AreaHitGameConcurrentLimitError: + raise HubError.AreaHitGameConcurrentLimitError + + def remove_area(self, area: AreaManager.Area): + """ + Remove an area from this hub's set of areas. + If the area is already a part of the hub, do nothing. + If any player of the hub is in this area, they are removed from the + hub. + If the hub has no areas remaining, it will be automatically destroyed. + + Parameters + ---------- + area : AreaManager.Area + Area to remove. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.AreaNotInGameError + If the area is already not part of the hub. + + """ + + self.unchecked_remove_area(area) + self.manager._check_structure() + + def unchecked_remove_area(self, area: AreaManager.Area): + """ + Remove an area from this hub's set of areas. + If the area is already a part of the hub, do nothing. + If any player of the hub is in this area, they are removed from the + hub. + If the hub has no areas remaining, it will be automatically destroyed. + + This method does not assert structural integrity. + + Parameters + ---------- + area : AreaManager.Area + Area to remove. + + Raises + ------ + HubError.GameIsUnmanagedError + If the hub was scheduled for deletion and thus does not accept any mutator + public method calls. + HubError.AreaNotInGameError + If the area is already not part of the hub. + + """ + + try: + super().unchecked_remove_area(area) + except GameWithAreasError.GameIsUnmanagedError: + raise HubError.GameIsUnmanagedError + except GameWithAreasError.AreaNotInGameError: + raise HubError.AreaNotInGameError + + def requires_areas(self) -> bool: + """ + Return whether the hub requires areas at all times. + + Returns + ------- + bool + Whether the hub requires areas at all times. + """ + + return super().requires_areas() + + def has_area(self, area: AreaManager.Area) -> bool: + """ + If the area is part of this hub's set of areas, return True; otherwise, return + False. + + Parameters + ---------- + area : AreaManager.Area + Area to check. + + Returns + ------- + bool + True if the area is part of the hub's set of areas, False otherwise. + + """ + + return super().has_area(area) + + def get_areas(self) -> Set[AreaManager.Area]: + """ + Return (a shallow copy of) the set of areas of this hub. + + Returns + ------- + Set[AreaManager.Area] + Set of areas of the hub. + + """ + + return super().get_areas() + + def get_area_concurrent_limit(self) -> Union[int, None]: + """ + Return the concurrent area membership limit of this hub. + + Returns + ------- + Union[int, None] + The concurrent area membership limit. + + """ + + return super().get_area_concurrent_limit() + + def get_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the hub, even those that are not players of + the hub. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the hub. + + """ + + return super().get_users_in_areas() + + def get_nonleader_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the hub, even those that are not players of + the hub, such that they are not leaders of the hub. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the hub that are not leaders of the hub. + + """ + + return super().get_nonleader_users_in_areas() + + def get_nonplayer_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the hub that are not players of the + hub. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the hub that are not players of the hub. + + """ + + return super().get_nonplayer_users_in_areas() + + def is_unmanaged(self): + """ + Return True if this hub is unmanaged, False otherwise. + + Returns + ------- + bool + True if unmanaged, False otherwise. + + """ + + return super().is_unmanaged() + + def destroy(self): + """ + Mark this hub as destroyed and notify its manager so that it is deleted. + If the hub is already destroyed, this function does nothing. + A hub marked for destruction will delete all of its timers, teams, remove all + its players and unsubscribe it from updates of its former players. + + This method is reentrant (it will do nothing though). + + Raises + ------ + HubError.ManagerCannotManageeNoManagees + If the manager is currently only managing this managee and the server is not shutting + down. + + Returns + ------- + None. + + """ + + self.unchecked_destroy() + self.manager._check_structure() + self._check_structure() # Manager will not check this otherwise. + + def _on_client_inbound_ms_check( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): + """ + Default callback for hub player signaling it wants to check if sending an IC + message is appropriate. The IC arguments can be passed by reference, so this also serves as + an opportunity to modify the IC message if neeeded. + + To indicate a message should not be sent, some TsuserverException can be raised. The + message of the exception will be sent to the client. + + Parameters + ---------- + player : ClientManager.Client + Player that wants to send the IC message. + contents : Dict[str, Any], optional + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_client_inbound_ms_check(player, contents=contents) + + def _on_client_inbound_ms_final( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): + """ + Default callback for hub player signaling it has sent an IC message. + This callback is executed after the server is done making all modifications to the MS packet + sent by the server. + + By default does nothing. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has sent an IC message. + contents : Dict[str, Any], optional + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_client_inbound_ms_final(player, contents=contents) + + def _on_area_client_inbound_ms_check( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + contents: Dict[str, Any] = None + ): + """ + Default callback for hub area signaling a client in the area sent an IC message. + Unlike the ClientManager.Client callback for send_ic_check, this one is triggered + regardless of whether the sender is part of the hub or not. This is useful for + example, to filter out messages sent by non-players. + + By default does nothing. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has entered. + client : ClientManager.Client, optional + The client that has send the IC message. The default is None. + contents : Dict[str, Any] + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_area_client_inbound_ms_check(area, client=client, contents=contents) + + def _on_area_destroyed(self, area: AreaManager.Area): + """ + Default callback for hub area signaling it was destroyed. + + By default it calls self.unchecked_remove_area(area). + + Parameters + ---------- + area : AreaManager.Area + Area that signaled it was destroyed. + + Returns + ------- + None. + + """ + + super()._on_area_destroyed(area) + +class _Hub(_HubTrivialInherited): + """ + A hub is a game with areas that hosts asset managers. + + Attributes + ---------- + server : TsuserverDR + Server the hub belongs to. + manager : HubManager + Manager for this hub. + listener : Listener + Standard listener of the hub. + + Callback Methods + ---------------- + _on_area_client_left_final + Method to perform once a client left an area of the hub. + _on_area_client_entered_final + Method to perform once a client entered an area of the hub. + _on_area_destroyed + Method to perform once an area of the hub is marked for destruction. + _on_client_inbound_ms_check + Method to perform once a player of the hub wants to send an IC message. + _on_client_inbound_ms_final + Method to perform once a player of the hub sends an IC message. + _on_client_change_character + Method to perform once a player of the hub has changed character. + _on_client_destroyed + Method to perform once a player of the hub is destroyed. + + """ + + # Invariants + # ---------- + # 1. The invariants from the parent class GameWithArea are satisfied. + + + def __init__( + self, + server: TsuserverDR, + manager: HubManager, + hub_id: str, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + area_concurrent_limit: Union[int, None] = None, + autoadd_on_client_enter: bool = False, + require_areas: bool = True, + ): + """ + Create a new hub. A hub should not be fully initialized anywhere else other than + some manager code, as otherwise the manager will not recognize the hub. + + Parameters + ---------- + server : TsuserverDR + Server the hub belongs to. + manager : HubManager + Manager for this hub. + hub_id : str + Identifier of the hub. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the hub supports. If None, it + indicates the hub has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of hubs managed by `manager` that any + player of this hub may belong to, including this hub. If None, it indicates + that this hub does not care about how many other hubs managed by `manager` each + of its players belongs to. Defaults to None. + require_invitation : bool, optional + If True, players can only be added to the hub if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the hub has no players left, the hub will + automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + require_leaders : bool, optional + If True, if at any point the hub has no leaders left, the hub will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + hub, and players that switch to something other than a participant character + will be automatically removed from the hub. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the hub supports. If None, it + indicates the hub has no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the hub supports. If None, it + indicates the hub has no timer limit. Defaults to None. + area_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of hubs managed by `manager` that any + area of this hub may belong to, including this hub. If None, it indicates + that this hub does not care about how many other hubs managed by + `manager` each of its areas belongs to. Defaults to 1 (an area may not be a part of + another hub managed by `manager` while being an area of this hub). + autoadd_on_client_enter : bool, optional + If True, nonplayer users that enter an area part of the hub will be automatically + added if permitted by the conditions of the hub. If False, no such adding will take + place. Defaults to False. + require_areas : bool, optional + If True, if at any point the hub has no areas left, the hub + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + """ + + super().__init__( + server, + manager, + hub_id, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + require_areas=require_areas, + ) + + self.background_manager = BackgroundManager(server, hub=self) + self.load_backgrounds() + + self.character_manager = CharacterManager(server, hub=self) + self.load_characters() + + self.music_manager = MusicManager(server, hub=self) + self.load_music() + + self.zone_manager = ZoneManager(server, self) + + # Has to be after character_manager to allow proper loading of areas, + # as those need to compute restricted characters + + self.area_manager = AreaManager(server, hub=self) + self.load_areas() + + self.trial_manager = TrialManager(self) + + self._password = str(secrets.randbelow(9000) + 1000) # Cute trick to get 4-digit number + + self.manager: HubManager # Setting for typing + + def get_type_name(self) -> str: + """ + Return the type name of the hub. Names are fully lowercase. + Implementations of the class should replace this with a human readable name of the hub. + + Returns + ------- + str + Type name of the hub. + + """ + + return "hub" + + def get_password(self) -> str: + """ + Get the password of the hub. + + Returns + ------- + str + Password. + """ + + return self._password + + def set_password(self, new_password: str): + """ + Set the password of the hub. + + Parameters + ---------- + new_password : str + New password. + """ + + self.unchecked_set_password(new_password) + self.manager._check_structure() + + def unchecked_set_password(self, new_password: str): + """ + Set the password of the hub. + + This method does not assert structural integrity. + + Parameters + ---------- + new_password : str + New password. + """ + + self._password = new_password + + def is_password(self, guess: str) -> bool: + """ + Return whether the guess is the hub password or not. + + This check is secure against timing attacks. + + Parameters + ---------- + guess : str + Guess. + + Returns + ------- + bool + Whether the guess is the password or not. + """ + + return Constants.secure_eq(guess, self._password) + + def load_areas(self, source_file: str = 'config/areas.yaml') -> List[AreaManager.Area]: + """ + Load an area list file. + + Parameters + ---------- + source_file : str + Relative path from server root folder to the area list file, by default + 'config/areas.yaml' + + Returns + ------- + List[AreaManager.Area] + Areas. + + Raises + ------ + ServerError.FileNotFoundError + If the file was not found. + ServerError.FileOSError + If there was an operating system error when opening the file. + ServerError.YAMLInvalidError + If the file was empty, had a YAML syntax error, or could not be decoded using UTF-8. + ServerError.FileSyntaxError + If the file failed verification for its asset type. + """ + + areas = self.area_manager.load_file(source_file) + return areas.copy() + + def load_backgrounds(self, source_file: str = 'config/backgrounds.yaml') -> List[str]: + """ + Load a background list file. + + Parameters + ---------- + source_file : str + Relative path from server root folder to the background list file, by default + 'config/backgrounds.yaml' + + Returns + ------- + List[str] + Backgrounds. + + Raises + ------ + ServerError.FileNotFoundError + If the file was not found. + ServerError.FileOSError + If there was an operating system error when opening the file. + ServerError.YAMLInvalidError + If the file was empty, had a YAML syntax error, or could not be decoded using UTF-8. + ServerError.FileSyntaxError + If the file failed verification for its asset type. + """ + + old_backgrounds = self.background_manager.get_backgrounds() + backgrounds = self.background_manager.load_file(source_file) + + if old_backgrounds == backgrounds: + # No change implies backgrounds still valid, do nothing more + return backgrounds.copy() + + # Make sure each area still has a valid background + default_background = self.background_manager.get_default_background() + for area in self.get_areas(): + if not self.background_manager.is_background(area.background) and not area.cbg_allowed: + # The area no longer has a valid background, so change it to some valid background + # like the first one + area.change_background(default_background) + area.broadcast_ooc(f'After a change in the background list, your area no longer ' + f'had a valid background. Switching to {default_background}.') + + return backgrounds.copy() + + def load_characters(self, source_file: str = 'config/characters.yaml') -> List[str]: + """ + Load a character list file. + + Parameters + ---------- + source_file : str, optional + Relative path from server root folder to the character list file, by default + 'config/characters.yaml' + + Returns + ------- + List[str] + Characters. + + Raises + ------ + ServerError.FileNotFoundError + If the file was not found. + ServerError.FileOSError + If there was an operating system error when opening the file. + ServerError.YAMLInvalidError + If the file was empty, had a YAML syntax error, or could not be decoded using UTF-8. + ServerError.FileSyntaxError + If the file failed verification for its asset type. + """ + + old_characters = self.character_manager.get_characters() + characters = self.character_manager.validate_file(source_file) + if old_characters == characters: + return characters.copy() + + # Inconsistent character list, so change to spectator those who lost their character. + old_client_char_names = {client: client.get_char_name() for client in self.get_players()} + + # Only now update internally. This is to allow `change_character` to work properly. + self.character_manager.load_file(source_file) + for client in self.get_players(): + old_char_name = old_client_char_names[client] + if client.packet_handler.ALLOWS_CHAR_LIST_RELOAD: + client.send_character_list(characters=characters) + should_change, change_to_char_id = self.character_manager.translate_character_id( + client, old_char_name=old_char_name, + ) + if should_change: + client.change_character(change_to_char_id, force=True, old_char=old_char_name) + else: + client.send_ooc('After a change in the character list, your client character list ' + 'is no longer synchronized. Please rejoin the server.') + client.change_character(-1, old_char=old_char_name) + return characters.copy() + + def load_music(self, music_list_file: str = 'config/music.yaml') -> List[Dict[str, Any]]: + """ + Load a music list file. + + Parameters + ---------- + source_file : str, optional + Relative path from server root folder to the music list file, by default + 'config/music.yaml' + + Returns + ------- + List[Dict[str, Any]] + Music. + + Raises + ------ + ServerError.FileNotFoundError + If the file was not found. + ServerError.FileOSError + If there was an operating system error when opening the file. + ServerError.YAMLInvalidError + If the file was empty, had a YAML syntax error, or could not be decoded using UTF-8. + ServerError.FileSyntaxError + If the file failed verification for its asset type. + """ + + music = self.music_manager.load_file(music_list_file) + return music.copy() + + def get_info(self) -> str: + output = f'== Hub {self.get_numerical_id()} ==' + output += f'\r\n*GMs: {len(self.get_leaders())}. NonGMs: {len(self.get_regulars())}' + output += f'\r\n*Area list: {self.area_manager.get_source_file()}' + output += f'\r\n*Background list: {self.background_manager.get_source_file()}' + output += f'\r\n*Character list: {self.character_manager.get_source_file()}' + output += f'\r\n*DJ list: {self.music_manager.get_source_file()}' + return output + + def refresh(self): + try: + self.background_manager.validate_file() + self.character_manager.validate_file() + self.music_manager.validate_file() + except ServerError.YAMLInvalidError as exc: + # The YAML exception already provides a full description. Just add the fact the + # refresh was undone to ease the nerves of the person who ran the command. + msg = (f'{exc} Refresh was undone.') + raise ServerError.YAMLInvalidError(msg) + except ServerError.FileSyntaxError as exc: + msg = f'{exc} Refresh was undone.' + raise ServerError.FileSyntaxError(msg) + + # Only on success refresh + self.load_backgrounds() + self.load_characters() + self.load_music() + + def unchecked_destroy(self): + """ + Mark this hub as destroyed and notify its manager so that it is deleted. + If the hub is already destroyed, this function does nothing. + + This method is reentrant (it will do nothing though). + + Raises + ------ + HubError.ManagerCannotManageeNoManagees + If the manager is currently only managing this managee and the server is not shutting + down. + + Returns + ------- + None. + + """ + + players = self.get_players() + areas = self.get_areas() + + super().unchecked_destroy() + + # Move to default area of default hub + new_hub = self.manager.get_default_managee() + new_area = new_hub.area_manager.default_area() + + for player in players: + if new_area.is_char_available(player.char_id): + new_char_id = player.char_id + else: + try: + new_char_id = new_area.get_rand_avail_char_id() + except AreaError: + new_char_id = -1 + + player.send_ooc(f'Your hub no longer exists. Moving you to the default area ' + f'{new_area.name} of hub {new_hub.get_numerical_id()}.') + player.change_hub(new_hub, ignore_checks=True, change_to=new_char_id, + ignore_notifications=True) + + # Move parties (independently) + for area in areas: + for party in area.parties.copy(): + party.area = new_area + new_area.add_party(party) + + def _on_area_client_left_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False, + ): + """ + Default callback for hub area signaling a client left. This is executed after + all other actions related to moving the player to a new area have been executed: + in particular, client.area holds the new area of the client. + + By default it removes the player from the hub if their new area is not part of + the hub. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has left. + client : ClientManager.Client, optional + The client that has left. The default is None. + new_area : AreaManager.Area + The new area the client has gone to. The default is None. + old_displayname : str, optional + The old displayed name of the client before they changed area. This will typically + change only if the client's character or showname are taken. The default is None. + ignore_bleeding : bool, optional + If the code should ignore actions regarding bleeding. The default is False. + ignore_autopass : bool, optional + If the code should ignore actions regarding autopass. The default is False. + + Returns + ------- + None. + + """ + + super()._on_area_client_left_final( + area, + client=client, + old_displayname=old_displayname, + ignore_bleeding=ignore_bleeding, + ignore_autopass=ignore_autopass, + ) + + def _on_area_client_entered_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_area: Union[AreaManager.Area, None] = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False, + ): + """ + Default callback for hub area signaling a client entered. + + By default adds a user to the hub if the hub is meant to + automatically add users that enter an area part of the hub. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has entered. + client : ClientManager.Client, optional + The client that has entered. The default is None. + old_area : AreaManager.Area + The old area the client has come from (possibly None for a newly connected user). The + default is None. + old_displayname : str, optional + The old displayed name of the client before they changed area. This will typically + change only if the client's character or showname are taken. The default is None. + ignore_bleeding : bool, optional + If the code should ignore actions regarding bleeding. The default is False. + ignore_autopass : bool, optional + If the code should ignore actions regarding autopass. The default is False. + + Returns + ------- + None. + + """ + + super()._on_area_client_entered_final( + area, + client=client, + old_area=old_area, + old_displayname=old_displayname, + ignore_bleeding=ignore_bleeding, + ignore_autopass=ignore_autopass, + ) + + def _on_client_change_character( + self, + player: ClientManager.Client, + old_char_id: int = -1, + old_char_name: str = '', + new_char_id: int = -1, + new_char_name: str = '', + ): + """ + Default callback for hub player signaling it has changed character. + + By default it only checks if the player is now no longer having a character. If that is + the case and the game requires all players have participant characters, the player is + automatically removed. + + Note that it may not necessarily be the case that the following hold: + 1. `old_char_name == player.hub.character_manager.get_character_name(old_char_id)`. + 2. `new_char_name == player.hub.character_manager.get_character_name(new_char_id)`. + This can occur for example if the character list changes, which prompts the player to + change character. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has changed character. + old_char_id : int, optional + Previous character ID. The default is -1. + old_char_name : str, optional + Previous character name. The default is the empty string. + new_char_id : int, optional + New character ID. The default is -1. + new_char_name : int, optional + New character name. The default is the empty string. + + Returns + ------- + None. + + """ + + super()._on_client_change_character( + player, + old_char_id=old_char_id, + old_char_name=old_char_name, + new_char_id=new_char_id, + new_char_name=new_char_name, + ) + + def _check_structure(self): + """ + Assert that all invariants specified in the class description are maintained. + + Raises + ------ + AssertionError + If any of the invariants are not maintained. + + """ + + return super()._check_structure() + + def __str__(self) -> str: + """ + Return a string representation of this hub. + + Returns + ------- + str + Representation. + """ + + return (f"Hub::{self.get_id()}:" + f"{self.get_players()}:{self.get_leaders()}:{self.get_invitations()}" + f"{self.get_timers()}:" + f"{self.get_teams()}:" + f"{self.get_areas()}") + + def __repr__(self) -> str: + """ + Return a representation of this hub. + + Returns + ------- + str + Printable representation. + + """ + + return (f'Hub(server, {self.manager.get_id()}, "{self.get_id()}", ' + f'player_limit={self.get_player_limit()}, ' + f'player_concurrent_limit={self.get_player_concurrent_limit()}, ' + f'require_players={self.requires_players()}, ' + f'require_invitations={self.requires_invitations()}, ' + f'require_leaders={self.requires_leaders()}, ' + f'require_participant_character={self.requires_participant_characters()}, ' + f'team_limit={self.get_team_limit()}, ' + f'timer_limit={self.get_timer_limit()}, ' + f'areas={self.get_areas()}), ' + f'|| ' + f'players={self.get_players()}, ' + f'invitations={self.get_invitations()}, ' + f'leaders={self.get_leaders()}, ' + f'timers={self.get_timers()}, ' + f'teams={self.get_teams()}, ' + f'unmanaged={self.is_unmanaged()}), ' + f')') + + +class _HubManagerTrivialInherited(GameWithAreasManager): + """ + This class should not be instantiated. + """ + + def new_managee( + self, + managee_type: Type[_Hub] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = False, # Overriden from parent + require_leaders: bool = False, # Overriden from parent + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + areas: Set[AreaManager.Area] = None, + area_concurrent_limit: Union[int, None] = 1, # Overriden from parent + autoadd_on_client_enter: bool = True, # Overiden from parent + autoadd_on_creation_existing_users: bool = False, + require_areas: bool = False, # Overriden from parent + **kwargs: Any, + ) -> _Hub: + """ + Create a new hub managed by this manager. Overriden default parameters include: + * A hub does not require leaders. + * A hub does not require players. + * An area cannot belong to two or more hubs at the same time. + + Parameters + ---------- + creator : ClientManager.Client, optional + The player who created this hub. If set, they will also be added to the hub. + Defaults to None. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the hub supports. If None, it + indicates the hub has no player limit. Defaults to None. + require_invitations : bool, optional + If True, users can only be added to the hub if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the hub loses all its players, the hub will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + hub, and players that switch to something other than a participant character + will be automatically removed from the hub. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the hub will support. If None, it + indicates the hub will have no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the hub will support. If None, it + indicates the hub will have no timer limit. Defaults to None. + area_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of hubs managed by `manager` that any + area of the created hub may belong to, including the created hub. If None, it + indicates that this hub does not care about how many other hubs managed by + `manager` each of its areas belongs to. Defaults to 1 (an area may not be a part of + another hub managed by `manager` while being an area of this hubs). + autoadd_on_client_enter : bool, optional + If True, nonplayer users that enter an area part of the game will be automatically + added if permitted by the conditions of the game. If False, no such adding will take + place. Defaults to False. + autoadd_on_creation_existing_users : bool + If the hub will attempt to add nonplayer users who were in an area added + to the hub on creation. Defaults to False. + require_areas : bool, optional + If True, if at any point the hub has no areas left, the hub + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + + Returns + ------- + _Hub + The created hub. + + Raises + ------ + HubError.ManagerTooManyGamesError + If the manager is already managing its maximum number of minigames. + Any error from the created hub's add_player(creator) + If the hub cannot add `creator` to the hub if given one. + + """ + + if managee_type is None: + managee_type = self.get_managee_type() + + hub = self.unchecked_new_managee( + managee_type=managee_type, + creator=creator, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + areas=areas, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + autoadd_on_creation_existing_users=autoadd_on_creation_existing_users, + require_areas=require_areas, + **kwargs, + ) + self._check_structure() + + return hub + + def get_managee_type(self) -> Type[_Hub]: + """ + Return the type of the hub that will be constructed by default with a call of + `new_managee`. + + Returns + ------- + Type[_Hub] + Type of the hub. + + """ + + return super().get_managee_type() + + def delete_managee(self, managee: _Hub) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a hub managed by this manager, so all its players no longer belong to + this hub. + + Parameters + ---------- + managee : _Hub + The hub to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the hub that was deleted. + + Raises + ------ + HubError.ManagerDoesNotManageGameError + If the manager does not manage the target hub. + HubError.ManagerCannotManageeNoManagees + If the manager is currently only managing the managee that is to be destroyed and the + server is not shutting down. + + """ + + game_id, game_players = self.unchecked_delete_managee(managee) + self._check_structure() + return game_id, game_players + + def manages_managee(self, game: _Hub): + """ + Return True if the hub is managed by this manager, False otherwise. + + Parameters + ---------- + game : _Hub + The game to check. + + Returns + ------- + bool + True if the manager manages this hub, False otherwise. + + """ + + return super().manages_managee(game) + + def get_managees(self) -> Set[_Hub]: + """ + Return (a shallow copy of) the hubs this manager manages. + + Returns + ------- + Set[_Hub] + Hubs this manager manages. + + """ + + return super().get_managees() + + def get_managee_by_id(self, managee_id: str) -> _Hub: + """ + If `managee_id` is the ID of a hub managed by this manager, return that. + + Parameters + ---------- + managee_id : str + ID of the hub this manager manages. + + Returns + ------- + _Hub + The hub with that ID. + + Raises + ------ + HubError.ManagerInvalidGameIDError + If `game_id` is not the ID of a hub this manager manages. + + """ + + try: + return super().get_managee_by_id(managee_id) + except GameWithAreasError.ManagerInvalidGameIDError: + raise HubError.ManagerInvalidGameIDError + + def get_managee_by_numerical_id(self, managee_numerical_id: Union[str, int]) -> _Hub: + """ + If `managee_numerical_id` is the numerical ID of a hub managed by this manager, + return the hub. + + Parameters + ---------- + managee_numerical_id : Union[str, int] + Numerical ID of the hub this manager manages. + + Returns + ------- + _Hub + The hub with that ID. + + Raises + ------ + HubError.ManagerInvalidGameIDError: + If `managee_numerical_id` is not the numerical ID of a hub + this manager manages. + + """ + + try: + return super().get_managee_by_numerical_id(managee_numerical_id) + except GameWithAreasError.ManagerInvalidGameIDError: + raise HubError.ManagerInvalidGameIDError + + def get_managee_limit(self) -> Union[int, None]: + """ + Return the hub limit of this manager. + + Returns + ------- + Union[int, None] + Game with areas limit. + + """ + + return super().get_managee_limit() + + def get_managee_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all hubs managed by this manager. + + Returns + ------- + Set[str] + The IDs of all managed hubs. + + """ + + return super().get_managee_ids() + + def get_managee_ids_to_managees(self) -> Dict[str, _Hub]: + """ + Return a mapping of the IDs of all hubs managed by this manager to their + associated hub. + + Returns + ------- + Dict[str, _Hub] + Mapping. + """ + + return super().get_managee_ids_to_managees() + + def get_managee_numerical_ids_to_managees(self) -> Dict[int, _Hub]: + """ + Return a mapping of the numerical IDs of all hubs managed by this manager to + their associated hubs. + + Returns + ------- + Dict[int, _Hub] + Mapping. + """ + + return super().get_managee_numerical_ids_to_managees() + + def get_managees_of_user(self, user: ClientManager.Client): + """ + Return (a shallow copy of) the hubs managed by this manager user `user` is a + player of. If the user is part of no such hub, an empty set is returned. + + Parameters + ---------- + user : ClientManager.Client + User whose hubs will be returned. + + Returns + ------- + Set[_Hub] + Hubs the player belongs to. + + """ + + return super().get_managees_of_user(user) + + def get_player_to_managees_map(self) -> Dict[ClientManager.Client, Set[_Hub]]: + """ + Return a mapping of the players part of any hub managed by this manager to the + hub managed by this manager such players belong to. + + Returns + ------- + Dict[ClientManager.Client, Set[_Hub]] + Mapping. + """ + + return super().get_player_to_managees_map() + + def get_users_in_some_managee(self) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) all the users that are part of some hub managed by + this manager. + + Returns + ------- + Set[ClientManager.Client] + Users in some managed hub. + + """ + + return super().get_users_in_some_managee() + + def is_managee_creatable(self) -> bool: + """ + Return whether a new hub can currently be created without creating one. + + Returns + ------- + bool + True if a hub can be currently created, False otherwise. + """ + + return super().is_managee_creatable() + + def get_managees_in_area(self, area: AreaManager.Area) -> Set[_Hub]: + """ + Return (a shallow copy of) all hubs managed by this manager that contain + the given area. + + Parameters + ---------- + area : AreaManager.Area + Area that all returned hubs must contain. + + Returns + ------- + Set[_Hub] + Hubs that contain the given area. + + """ + + return super().get_managees_in_area(area) + + def find_area_concurrent_limiting_managee( + self, + area: AreaManager.Area + ) -> Union[_Hub, None]: + """ + For area `area`, find a hub `most_restrictive_game` managed by this manager + such that, if `area` were to be added to another hub managed by this manager, + they would violate `most_restrictive_game`'s concurrent area membership limit. + If no such hub exists (or the area is not an area of any hub + managed by this manager), return None. + If multiple such hubs exist, any one of them may be returned. + + Parameters + ---------- + area : AreaManager.Area + Area to test. + + Returns + ------- + Union[_Hub, None] + Limiting hub as previously described if it exists, None otherwise. + + """ + + return super().find_area_concurrent_limiting_managee(area) + + def get_areas_to_managees_map(self) -> Dict[ClientManager.Client, Set[_Hub]]: + """ + Return a mapping of the areas part of any hub managed by this manager to the + hub managed by this manager such players belong to. + + Returns + ------- + Dict[ClientManager.Client, Set[_Hub]] + Mapping. + """ + + return super().get_areas_to_managees_map() + + def get_id(self) -> str: + """ + Return the ID of this manager. This ID is guaranteed to be unique among + simultaneously existing Python objects. + + Returns + ------- + str + ID. + + """ + + return super().get_id() + + def find_player_concurrent_limiting_managee( + self, + user: ClientManager.Client + ) -> Union[_Hub, None]: + """ + For user `user`, find a hub `most_restrictive_game` managed by this manager such + that, if `user` were to join another hub managed by this manager, they would + violate `most_restrictive_game`'s concurrent player membership limit. + If no such hub exists (or the player is not member of any hub + managed by this manager), return None. + If multiple such hubs exist, any one of them may be returned. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Returns + ------- + Union[_Hub, None] + Limiting hub as previously described if it exists, None otherwise. + + """ + + return super().find_player_concurrent_limiting_managee(user) + + +class HubManager(_HubManagerTrivialInherited): + """ + A hub manager is a game with areas manager with dedicated hub management functions. + + Attributes + ---------- + server : TsuserverDR + Server the hub manager belongs to. + + """ + + # Invariants + # ---------- + # 1. If `self.get_managees()` is empty, then `self._ever_had_hubs` is True. + # 2. The invariants of the parent class are maintained. + + def __init__( + self, + server: TsuserverDR, + managee_limit: Union[int, None] = None, + default_managee_type: Type[_Hub] = None, + ): + """ + Create a hub manager object. + + Parameters + ---------- + server : TsuserverDR + The server this hub manager belongs to. + managee_limit : int, optional + The maximum number of hub this manager can handle. Defaults to None + (no limit). + default_managee_type : Type[_Hub], optional + The default type of hub this manager will create. Defaults to None (and then + converted to _Hub). + + """ + + if default_managee_type is None: + default_managee_type = _Hub + + self._ever_had_hubs = False + + super().__init__( + server, + managee_limit=managee_limit, + default_managee_type=default_managee_type + ) + + def unchecked_new_managee( + self, + managee_type: Type[_Hub] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = False, # Overriden from parent + require_leaders: bool = False, # Overriden from parent + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + areas: Set[AreaManager.Area] = None, + area_concurrent_limit: Union[int, None] = 1, # Overriden from parent + autoadd_on_client_enter: bool = True, # Overriden from parent + autoadd_on_creation_existing_users: bool = False, + require_areas: bool = False, # Overriden from parent + **kwargs: Any, + ) -> _Hub: + """ + Create a new hub managed by this manager. Overriden default parameters include: + * A hub does not require leaders. + * A hub does not require players. + * An area cannot belong to two or more hubs at the same time. + + This method does not assert structural integrity. + + Parameters + ---------- + creator : ClientManager.Client, optional + The player who created this hub. If set, they will also be added to the hub. + Defaults to None. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the hub supports. If None, it + indicates the hub has no player limit. Defaults to None. + require_invitations : bool, optional + If True, users can only be added to the hub if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the hub loses all its players, the hub will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to False. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + hub, and players that switch to something other than a participant character + will be automatically removed from the hub. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the hub will support. If None, it + indicates the hub will have no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the hub will support. If None, it + indicates the hub will have no timer limit. Defaults to None. + area_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of hubs managed by `manager` that any + area of the created hub may belong to, including the created hub. If None, it + indicates that this hub does not care about how many other hubs managed by + `manager` each of its areas belongs to. Defaults to 1 (an area may not be a part of + another hub managed by `manager` while being an area of this hubs). + autoadd_on_client_enter : bool, optional + If True, nonplayer users that enter an area part of the game will be automatically + added if permitted by the conditions of the game. If False, no such adding will take + place. Defaults to False. + autoadd_on_creation_existing_users : bool + If the hub will attempt to add nonplayer users who were in an area added + to the hub on creation. Defaults to False. + require_areas : bool, optional + If True, if at any point the hub has no areas left, the hub + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + + Returns + ------- + _Hub + The created hub. + + Raises + ------ + HubError.ManagerTooManyGamesError + If the manager is already managing its maximum number of minigames. + Any error from the created hub's add_player(creator) + If the hub cannot add `creator` to the hub if given one. + + """ + + if managee_type is None: + managee_type = self.get_managee_type() + + try: + hub: _Hub = super().unchecked_new_managee( + managee_type=managee_type, + creator=creator, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + areas=areas, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + autoadd_on_creation_existing_users=autoadd_on_creation_existing_users, + require_areas=require_areas, + # kwargs + **kwargs, + ) + except GameWithAreasError.ManagerTooManyGamesError: + raise HubError.ManagerTooManyGamesError + + self._ever_had_hubs = True + return hub + + def unchecked_delete_managee( + self, + managee: _Hub + ) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a hub managed by this manager, so all its players no longer belong to + this hub. + + Parameters + ---------- + managee : _Hub + The hub to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the hub that was deleted. + + Raises + ------ + HubError.ManagerDoesNotManageGameError + If the manager does not manage the target hub. + HubError.ManagerCannotManageeNoManagees + If the manager is currently only managing the managee that is to be destroyed and the + server is not shutting down. + + """ + + if self.get_managees() == {managee}: + raise HubError.ManagerCannotManageeNoManagees + + try: + return super().unchecked_delete_managee(managee) + except GameWithAreasError.ManagerDoesNotManageGameError: + raise HubError.ManagerDoesNotManageGameError + + def get_managee_of_user(self, user: ClientManager.Client) -> _Hub: + """ + Get the hub the user is in. + + Parameters + ---------- + user : ClientManager.Client + User to check. + + Raises + ------ + HubError.UserNotPlayerError + If the user is not in a hub managed by this manager. + + Returns + ------- + HubManager.Hub + Hub of the user. + + """ + + games = self.get_managees_of_user(user) + hubs = {game for game in games if isinstance(game, _Hub)} + if not hubs: + raise HubError.UserNotPlayerError + if len(hubs) > 1: + raise RuntimeError(hubs) + return next(iter(hubs)) + + def get_available_managee_id(self): + """ + Get a hub ID that no other hub managed by this manager has. + + Returns + ------- + str + A unique hub ID. + + Raises + ------ + HubError.ManagerTooManyGamesError + If the manager is already managing its maximum number of games. + + """ + + game_number = 0 + game_limit = self.get_managee_limit() + while game_limit is None or game_number < game_limit: + new_game_id = f'H{game_number}' + if new_game_id not in self.get_managee_ids(): + return new_game_id + game_number += 1 + raise HubError.ManagerTooManyGamesError + + def get_default_managee(self) -> _Hub: + id_to_managees = self.get_managee_ids_to_managees() + earliest_id = sorted(id_to_managees.keys())[0] + return id_to_managees[earliest_id] + + def get_client_view(self, client: ClientManager.Client) -> List[str]: + # Now add areas + prepared_list = list() + prepared_list.append(Constants.get_first_area_list_item('AREA', client.hub, client.area)) + + for (num_id, hub) in self.get_managee_numerical_ids_to_managees().items(): + name = hub.get_name() + if not name: + name = hub.get_id() + + prepared_list.append(f'{num_id}-{name}') + + return prepared_list + + def _check_structure(self): + """ + Assert that all invariants specified in the class description are maintained. + + Raises + ------ + AssertionError + If any of the invariants are not maintained. + + """ + + hubs = self.get_managees() + + # 1. + if not hubs and self._ever_had_hubs and not self.server.shutting_down: + assert not self._ever_had_hubs, ( + f'For hub manager {self}, expected that it had no hubs managed only if it had ' + f'never had any hubs or the server was shutting down, found it managed no hubs ' + f'after it had hubs {hubs} beforehand and the server is currently not shutting ' + f'down.' + ) + + # 2. + super()._check_structure() diff --git a/server/hubbedgame_manager.py b/server/hubbedgame_manager.py new file mode 100644 index 000000000..756dad464 --- /dev/null +++ b/server/hubbedgame_manager.py @@ -0,0 +1,2923 @@ +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. +# +# Copyright (C) 2016 argoneus (original tsuserver3) +# (C) 2018-22 Chrezm/Iuvee (further additions) +# +# 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 3 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, see . + +""" +Module that contains the base hubbed game class. +""" + +from __future__ import annotations + +import typing +from typing import Callable, Dict, Set, Any, Tuple, Type, Union + +from server.exceptions import HubbedGameError, GameWithAreasError +from server.gamewithareas_manager import _GameWithAreas, GameWithAreasManager + +if typing.TYPE_CHECKING: + # Avoid circular referencing + from server.area_manager import AreaManager + from server.client_manager import ClientManager + from server.game_manager import _Team + from server.hub_manager import _Hub + from server.timer_manager import Timer + from server.tsuserver import TsuserverDR + + +class _HubbedGameTrivialInherited(_GameWithAreas): + """ + This class should not be instantiated. + """ + + def get_id(self) -> str: + """ + Return the ID of this hubbed game. + + Returns + ------- + str + The ID. + + """ + + return super().get_id() + + def get_numerical_id(self) -> int: + """ + Return the numerical portion of the ID of this hubbed game. + + Returns + ------- + int + Numerical portion of the ID. + """ + + return super().get_numerical_id() + + def get_name(self) -> str: + """ + Get the name of the hubbed game. + + Returns + ------- + str + Name. + """ + + return super().get_name() + + def set_name(self, name: str): + """ + Set the name of the hubbed game. + + Parameters + ---------- + name : str + Name. + """ + + self.unchecked_set_name(name) + self.manager._check_structure() + + def unchecked_set_name(self, name: str): + """ + Set the name of the hubbed game. + + This method does not assert structural integrity. + + Parameters + ---------- + name : str + Name. + """ + + super().unchecked_set_name(name) + + def get_player_limit(self) -> Union[int, None]: + """ + Return the player membership limit of this hubbed game. + + Returns + ------- + Union[int, None] + The player membership limit. + + """ + + return super().get_player_limit() + + def get_player_concurrent_limit(self) -> Union[int, None]: + """ + Return the concurrent player membership limit of this hubbed game. + + Returns + ------- + Union[int, None] + The concurrent player membership limit. + + """ + + return super().get_player_concurrent_limit() + + def get_players( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of players of this hubbed game that satisfy a + condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all players returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) players of this hubbed game. + + """ + + return super().get_players(cond=cond) + + def is_player(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is a player of the hubbed game. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Returns + ------- + bool + True if the user is a player, False otherwise. + + """ + + return super().is_player(user) + + def add_player(self, user: ClientManager.Client): + """ + Make a user a player of the hubbed game. By default this player will not be a leader, + unless the hubbed game has no leaders and it requires a leader. + It will also subscribe the hubbed game to the player so it can listen to its updates. + + Parameters + ---------- + user : ClientManager.Client + User to add to the hubbed game. They must be in an area part of the hubbed game. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.UserNotInAreaError + If the user is not in an area part of the hubbed game. + HubbedGameError.UserHasNoCharacterError + If the user has no character but the hubbed game requires that all players have + characters. + HubbedGameError.UserNotInvitedError + If the hubbed game requires players be invited to be added and the user is not + invited. + HubbedGameError.UserAlreadyPlayerError + If the user to add is already a user of the hubbed game. + HubbedGameError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the hubbed game + managed by the manager of this hubbed game, or by virtue of joining this + hubbed game they would violate this hubbed game's concurrent player membership + limit. + HubbedGameError.GameIsFullError + If the hubbed game reached its player limit. + + """ + + self.unchecked_add_player(user) + self.manager._check_structure() + + def unchecked_add_player(self, user: ClientManager.Client): + """ + Make a user a player of the hubbed game. By default this player will not be a leader, + unless the hubbed game has no leaders and it requires a leader. + It will also subscribe the hubbed game to the player so it can listen to its updates. + + Parameters + ---------- + user : ClientManager.Client + User to add to the hubbed game. They must be in an area part of the hubbed game. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.UserNotInAreaError + If the user is not in an area part of the hubbed game. + HubbedGameError.UserHasNoCharacterError + If the user has no character but the hubbed game requires that all players have + characters. + HubbedGameError.UserNotInvitedError + If the hubbed game requires players be invited to be added and the user is not + invited. + HubbedGameError.UserAlreadyPlayerError + If the user to add is already a user of the hubbed game. + HubbedGameError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the hubbed game + managed by the manager of this hubbed game, or by virtue of joining this + hubbed game they would violate this hubbed game's concurrent player membership + limit. + HubbedGameError.GameIsFullError + If the hubbed game reached its player limit. + + """ + + try: + super().unchecked_add_player(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubbedGameError.GameIsUnmanagedError + except GameWithAreasError.UserNotInAreaError: + raise HubbedGameError.UserNotInAreaError + except GameWithAreasError.UserHasNoCharacterError: + raise HubbedGameError.UserHasNoCharacterError + except GameWithAreasError.UserNotInvitedError: + raise HubbedGameError.UserNotInvitedError + except GameWithAreasError.UserAlreadyPlayerError: + raise HubbedGameError.UserAlreadyPlayerError + except GameWithAreasError.UserHitGameConcurrentLimitError: + raise HubbedGameError.UserHitGameConcurrentLimitError + except GameWithAreasError.GameIsFullError: + raise HubbedGameError.GameIsFullError + + def remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this hubbed game. If they were part of a team + managed by this hubbed game, they will also be removed from said team. It will also + unsubscribe the hubbed game from the player so it will no longer listen to its updates. + + If the hubbed game required that there it always had players and by calling this method + the hubbed game had no more players, the hubbed game will automatically be scheduled + for deletion. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.UserNotPlayerError + If the user to remove is already not a player of this hubbed game. + + """ + + self.unchecked_remove_player(user) + self.manager._check_structure() + + def unchecked_remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this hubbed game. If they were part of a team + managed by this hubbed game, they will also be removed from said team. It will also + unsubscribe the hubbed game from the player so it will no longer listen to its updates. + + If the hubbed game required that there it always had players and by calling this method + the hubbed game had no more players, the hubbed game will automatically be scheduled + for deletion. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.UserNotPlayerError + If the user to remove is already not a player of this hubbed game. + + """ + try: + super().unchecked_remove_player(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubbedGameError.GameIsUnmanagedError + except GameWithAreasError.UserNotPlayerError: + raise HubbedGameError.UserNotPlayerError + + def requires_players(self) -> bool: + """ + Return whether the hubbed game requires players at all times. + + Returns + ------- + bool + Whether the hubbed game requires players at all times. + """ + + return super().requires_players() + + def get_invitations( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of invited users of this hubbed game that satisfy a + condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all invited users returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) invited users of this hubbed game. + + """ + + return super().get_invitations(cond=cond) + + def is_invited(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is invited to the hubbed game. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Raises + ------ + HubbedGameError.UserAlreadyPlayerError + If the user is a player of this hubbed game. + + Returns + ------- + bool + True if the user is invited, False otherwise. + + """ + + try: + return super().is_invited(user) + except GameWithAreasError.UserAlreadyPlayerError: + raise HubbedGameError.UserAlreadyPlayerError + + def add_invitation(self, user: ClientManager.Client): + """ + Mark a user as invited to this hubbed game. + + Parameters + ---------- + user : ClientManager.Client + User to invite to the hubbed game. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameDoesNotTakeInvitationsError + If the hubbed game does not require users be invited to the hubbed game. + HubbedGameError.UserAlreadyInvitedError + If the player to invite is already invited to the hubbed game. + HubbedGameError.UserAlreadyPlayerError + If the player to invite is already a player of the hubbed game. + + """ + + self.unchecked_add_invitation(user) + self.manager._check_structure() + + def unchecked_add_invitation(self, user: ClientManager.Client): + """ + Mark a user as invited to this hubbed game. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to invite to the hubbed game. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameDoesNotTakeInvitationsError + If the hubbed game does not require users be invited to the hubbed game. + HubbedGameError.UserAlreadyInvitedError + If the player to invite is already invited to the hubbed game. + HubbedGameError.UserAlreadyPlayerError + If the player to invite is already a player of the hubbed game. + + """ + + try: + super().unchecked_add_invitation(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubbedGameError.GameIsUnmanagedError + except GameWithAreasError.GameDoesNotTakeInvitationsError: + raise HubbedGameError.GameDoesNotTakeInvitationsError + except GameWithAreasError.UserAlreadyInvitedError: + raise HubbedGameError.UserAlreadyInvitedError + except GameWithAreasError.UserAlreadyPlayerError: + raise HubbedGameError.UserAlreadyPlayerError + + def remove_invitation(self, user: ClientManager.Client): + """ + Mark a user as no longer invited to this hubbed game (uninvite). + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameDoesNotTakeInvitationsError + If the hubbed game does not require users be invited to the hubbed game. + HubbedGameError.UserNotInvitedError + If the user to uninvite is already not invited to this hubbed game. + + """ + + self.unchecked_remove_invitation(user) + self.manager._check_structure() + + def unchecked_remove_invitation(self, user: ClientManager.Client): + """ + Mark a user as no longer invited to this hubbed game (uninvite). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameDoesNotTakeInvitationsError + If the hubbed game does not require users be invited to the hubbed game. + HubbedGameError.UserNotInvitedError + If the user to uninvite is already not invited to this hubbed game. + + """ + + try: + super().unchecked_remove_invitation(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubbedGameError.GameIsUnmanagedError + except GameWithAreasError.GameDoesNotTakeInvitationsError: + raise HubbedGameError.GameDoesNotTakeInvitationsError + except GameWithAreasError.UserNotInvitedError: + raise HubbedGameError.UserNotInvitedError + + def requires_invitations(self): + """ + Return True if the hubbed game requires players be invited before being allowed to join + the hubbed game, False otherwise. + + Returns + ------- + bool + True if the hubbed game requires players be invited before being allowed to join + the hubbed game, False otherwise. + """ + + return super().requires_invitations() + + def get_leaders( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of leaders of this hubbed game that satisfy a condition + if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all leaders returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) leaders of this hubbed game. + + """ + + return super().get_leaders(cond=cond) + + def get_regulars( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of players of this hubbed game that are regulars and + satisfy a condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all regulars returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) regulars of this hubbed game. + + """ + + return super().get_regulars(cond=cond) + + def is_leader(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is a leader of the hubbed game. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Raises + ------ + HubbedGameError.UserNotPlayerError + If the player to test is not a player of this hubbed game. + + Returns + ------- + bool + True if the player is a user, False otherwise. + + """ + + try: + return super().is_leader(user) + except GameWithAreasError.UserNotPlayerError: + raise HubbedGameError.UserNotPlayerError + + def add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this hubbed game (promote to leader). + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.UserNotPlayerError + If the player to promote is not a player of this hubbed game. + HubbedGameError.UserAlreadyLeaderError + If the player to promote is already a leader of this hubbed game. + + """ + + self.unchecked_add_leader(user) + self.manager._check_structure() + + def unchecked_add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this hubbed game (promote to leader). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.UserNotPlayerError + If the player to promote is not a player of this hubbed game. + HubbedGameError.UserAlreadyLeaderError + If the player to promote is already a leader of this hubbed game. + + """ + + try: + super().unchecked_add_leader(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubbedGameError.GameIsUnmanagedError + except GameWithAreasError.UserNotPlayerError: + raise HubbedGameError.UserNotPlayerError + except GameWithAreasError.UserAlreadyLeaderError: + raise HubbedGameError.UserAlreadyLeaderError + + def remove_leader(self, user: ClientManager.Client): + """ + Make a user no longer leader of this hubbed game (demote). + + Parameters + ---------- + user : ClientManager.Client + User to demote. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.UserNotPlayerError + If the player to demote is not a player of this hubbed game. + HubbedGameError.UserNotLeaderError + If the player to demote is already not a leader of this hubbed game. + + """ + + self.unchecked_remove_leader(user) + self.manager._check_structure() + + def unchecked_remove_leader(self, user: ClientManager.Client): + """ + Make a user no longer leader of this hubbed game (demote). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to demote. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.UserNotPlayerError + If the player to demote is not a player of this hubbed game. + HubbedGameError.UserNotLeaderError + If the player to demote is already not a leader of this hubbed game. + + """ + + try: + super().unchecked_remove_leader(user) + except GameWithAreasError.GameIsUnmanagedError: + raise HubbedGameError.GameIsUnmanagedError + except GameWithAreasError.UserNotPlayerError: + raise HubbedGameError.UserNotPlayerError + except GameWithAreasError.UserNotLeaderError: + raise HubbedGameError.UserNotLeaderError + + def has_ever_had_players(self) -> bool: + """ + Return True if a player has ever been added to this hubbed game, False otherwise. + + Returns + ------- + bool + True if the hubbed game has ever had a player added, False otherwise. + + """ + + return super().has_ever_had_players() + + def requires_leaders(self) -> bool: + """ + Return whether the hubbed game requires leaders at all times. + + Returns + ------- + bool + Whether the hubbed game requires leaders at all times. + """ + + return super().requires_leaders() + + def requires_participant_characters(self) -> bool: + """ + Return whether the hubbed game requires players have a participant character at all times. + + Returns + ------- + bool + Whether the hubbed game requires players have a participant character at all times. + """ + + return super().requires_participant_characters() + + def new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this hubbed game with given parameters. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the hubbed game will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameTooManyTimersError + If the hubbed game is already managing its maximum number of timers. + + """ + + timer = self.unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + self.manager._check_structure() + return timer + + def unchecked_new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this hubbed game with given parameters. + + This method does not assert structural integrity. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the hubbed game will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameTooManyTimersError + If the hubbed game is already managing its maximum number of timers. + + """ + + try: + return super().unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + except GameWithAreasError.GameIsUnmanagedError: + raise HubbedGameError.GameIsUnmanagedError + except GameWithAreasError.GameTooManyTimersError: + raise HubbedGameError.GameTooManyTimersError + + def delete_timer(self, timer: Timer) -> str: + """ + Delete a timer managed by this hubbed game, terminating it first if needed. + + Parameters + ---------- + timer : Timer + The timer to delete. + + Returns + ------- + str + The ID of the timer that was deleted. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameDoesNotManageTimerError + If the hubbed game does not manage the target timer. + + """ + + timer_id = self.unchecked_delete_timer(timer) + self.manager._check_structure() + return timer_id + + def unchecked_delete_timer(self, timer: Timer) -> str: + """ + Delete a timer managed by this hubbed game, terminating it first if needed. + + This method does not assert structural integrity. + + Parameters + ---------- + timer : Timer + The timer to delete. + + Returns + ------- + str + The ID of the timer that was deleted. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameDoesNotManageTimerError + If the hubbed game does not manage the target timer. + + """ + + try: + return super().unchecked_delete_timer(timer) + except GameWithAreasError.GameIsUnmanagedError: + raise HubbedGameError.GameIsUnmanagedError + except GameWithAreasError.GameDoesNotManageTimerError: + raise HubbedGameError.GameDoesNotManageTimerError + + def get_timers(self) -> Set[Timer]: + """ + Return (a shallow copy of) the timers this hubbed game manages. + + Returns + ------- + Set[Timer] + Timers this hubbed game manages. + + """ + + return super().get_timers() + + def get_timer_by_id(self, timer_id: str) -> Timer: + """ + If `timer_tag` is the ID of a timer managed by this hubbed game, return that timer. + + Parameters + ---------- + timer_id: str + ID of timer this hubbed game manages. + + Returns + ------- + Timer + The timer whose ID matches the given ID. + + Raises + ------ + HubbedGameError.GameInvalidTimerIDError: + If `timer_tag` is a str and it is not the ID of a timer this hubbed game manages. + + """ + + try: + return super().get_timer_by_id(timer_id) + except GameWithAreasError.GameInvalidTimerIDError: + raise HubbedGameError.GameInvalidTimerIDError + + def get_timer_limit(self) -> Union[int, None]: + """ + Return the timer limit of this hubbed game. + + Returns + ------- + Union[int, None] + Timer limit. + + """ + + return super().get_timer_limit() + + def get_timer_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all timers managed by this hubbed game. + + Returns + ------- + Set[str] + The IDs of all managed timers. + + """ + + return super().get_timer_ids() + + def new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this hubbed game. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this hubbed game. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + + Returns + ------- + _Team + The created team. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameTooManyTeamsError + If the hubbed game is already managing its maximum number of teams. + HubbedGameError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this hubbed game. + + """ + + team = self.unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + self.manager._check_structure() + return team + + def unchecked_new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this hubbed game. + + This method does not assert structural integrity. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this hubbed game. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + + Returns + ------- + _Team + The created team. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameTooManyTeamsError + If the hubbed game is already managing its maximum number of teams. + HubbedGameError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this hubbed game. + + """ + + try: + return super().unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + except GameWithAreasError.GameIsUnmanagedError: + raise HubbedGameError.GameIsUnmanagedError + except GameWithAreasError.GameTooManyTeamsError: + raise HubbedGameError.GameTooManyTeamsError + except GameWithAreasError.UserInAnotherTeamError: + raise HubbedGameError.UserInAnotherTeamError + + def delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this hubbed game. + + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameDoesNotManageTeamError + If the hubbed game does not manage the target team. + + """ + + team_id, players = self.unchecked_delete_team(team) + self.manager._check_structure() + return team_id, players + + def unchecked_delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this hubbed game. + + This method does not assert structural integrity. + + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.GameDoesNotManageTeamError + If the hubbed game does not manage the target team. + + """ + + try: + return super().unchecked_delete_team(team) + except GameWithAreasError.GameIsUnmanagedError: + raise HubbedGameError.GameIsUnmanagedError + except GameWithAreasError.GameDoesNotManageTeamError: + raise HubbedGameError.GameDoesNotManageTeamError + + def manages_team(self, team: _Team) -> bool: + """ + Return True if the team is managed by this hubbed game, False otherwise. + + Parameters + ---------- + team : _Team + The team to check. + + Returns + ------- + bool + True if the hubbed game manages this team, False otherwise. + + """ + + return super().manages_team(team) + + def get_teams(self) -> Set[_Team]: + """ + Return (a shallow copy of) the teams this hubbed game manages. + + Returns + ------- + Set[_Team] + Teams this hubbed game manages. + + """ + + return super().get_teams() + + def get_team_by_id(self, team_id: str) -> _Team: + """ + If `team_id` is the ID of a team managed by this hubbed game, return the team. + + Parameters + ---------- + team_id : str + ID of the team this hubbed game manages. + + Returns + ------- + _Team + The team that matches the given ID. + + Raises + ------ + HubbedGameError.GameInvalidTeamIDError: + If `team_id` is not the ID of a team this hubbed game manages. + + """ + + try: + return super().get_team_by_id(team_id) + except GameWithAreasError.GameInvalidTeamIDError: + raise HubbedGameError.GameInvalidTeamIDError + + def get_team_limit(self) -> Union[int, None]: + """ + Return the team limit of this hubbed game. + + Returns + ------- + Union[int, None] + Team limit. + + """ + + return super().get_team_limit() + + def get_team_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all teams managed by this hubbed game. + + Returns + ------- + Set[str] + The IDs of all managed teams. + + """ + + return super().get_team_ids() + + def get_teams_of_user(self, user: ClientManager.Client) -> Set[_Team]: + """ + Return (a shallow copy of) the teams managed by this hubbed game user `user` is a player + of. If the user is part of no such team, an empty set is returned. + + Parameters + ---------- + user : ClientManager.Client + User whose teams will be returned. + + Returns + ------- + Set[_Team] + Teams the player belongs to. + + """ + + return super().get_teams_of_user(user) + + def get_users_in_some_team(self): + """ + Return (a shallow copy of) all the users that are part of some team managed by this game + with areas. + + Returns + ------- + Set[ClientManager.Client] + Users in some managed team. + + """ + + return super().get_users_in_some_team() + + def get_available_team_id(self) -> str: + """ + Get a team ID that no other team managed by this team has. + + Returns + ------- + str + A unique team ID. + + Raises + ------ + HubbedGameError.GameTooManyTeamsError + If the hubbed game is already managing its maximum number of teams. + + """ + + try: + return super().get_available_team_id() + except GameWithAreasError.GameTooManyTeamsError: + raise HubbedGameError.GameTooManyTeamsError + + def get_autoadd_on_client_enter(self) -> bool: + """ + Return True if the hubbed game will always attempt to add nonplayer users who enter an + area part of the hubbed game, False otherwise. + + Returns + ------- + bool + True if the hubbed game will always attempt to add nonplayer users who enter an area + part of the hubbed game, False otherwise. + """ + + return super().get_autoadd_on_client_enter() + + def set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + Parameters + ---------- + new_value : bool + New value. + + """ + + self.unchecked_set_autoadd_on_client_enter(new_value) + self.manager._check_structure() + + def unchecked_set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + This method does not assert structural integrity. + + Parameters + ---------- + new_value : bool + New value. + + """ + + super().unchecked_set_autoadd_on_client_enter(new_value) + + def add_area(self, area: AreaManager.Area): + """ + Add an area to this hubbed game's set of areas. + + Parameters + ---------- + area : AreaManager.Area + Area to add. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.AreaAlreadyInGameError + If the area is already part of the hubbed game. + HubbedGameError.AreaHitGameConcurrentLimitError. + If `area` has reached the concurrent area membership limit of any of the games with + areas it belongs to managed by this manager, or by virtue of adding this area it will + violate this hubbed game's concurrent area membership limit. + + """ + + self.unchecked_add_area(area) + self.manager._check_structure() + + def remove_area(self, area: AreaManager.Area): + """ + Remove an area from this hubbed game's set of areas. + If the area is already a part of the hubbed game, do nothing. + If any player of the hubbed game is in this area, they are removed from the + hubbed game. + If the hubbed game has no areas remaining, it will be automatically destroyed. + + Parameters + ---------- + area : AreaManager.Area + Area to remove. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.AreaNotInGameError + If the area is already not part of the hubbed game. + + """ + + self.unchecked_remove_area(area) + self.manager._check_structure() + + def unchecked_remove_area(self, area: AreaManager.Area): + """ + Remove an area from this hubbed game's set of areas. + If the area is already a part of the hubbed game, do nothing. + If any player of the hubbed game is in this area, they are removed from the + hubbed game. + If the hubbed game has no areas remaining, it will be automatically destroyed. + + This method does not assert structural integrity. + + Parameters + ---------- + area : AreaManager.Area + Area to remove. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.AreaNotInGameError + If the area is already not part of the hubbed game. + + """ + + try: + super().unchecked_remove_area(area) + except GameWithAreasError.GameIsUnmanagedError: + raise HubbedGameError.GameIsUnmanagedError + except GameWithAreasError.AreaNotInGameError: + raise HubbedGameError.AreaNotInGameError + + def requires_areas(self) -> bool: + """ + Return whether the hubbed game requires areas at all times. + + Returns + ------- + bool + Whether the hubbed game requires areas at all times. + """ + + return super().requires_areas() + + def has_area(self, area: AreaManager.Area) -> bool: + """ + If the area is part of this hubbed game's set of areas, return True; otherwise, return + False. + + Parameters + ---------- + area : AreaManager.Area + Area to check. + + Returns + ------- + bool + True if the area is part of the hubbed game's set of areas, False otherwise. + + """ + + return super().has_area(area) + + def get_areas(self) -> Set[AreaManager.Area]: + """ + Return (a shallow copy of) the set of areas of this hubbed game. + + Returns + ------- + Set[AreaManager.Area] + Set of areas of the hubbed game. + + """ + + return super().get_areas() + + def get_area_concurrent_limit(self) -> Union[int, None]: + """ + Return the concurrent area membership limit of this hubbed game. + + Returns + ------- + Union[int, None] + The concurrent area membership limit. + + """ + + return super().get_area_concurrent_limit() + + def get_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the hubbed game, even those that are not players of + the hubbed game. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the hubbed game. + + """ + + return super().get_users_in_areas() + + def get_nonleader_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the hubbed game, even those that are not players of + the hubbed game, such that they are not leaders of the hubbed game. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the hubbed game that are not leaders of the hubbed game. + + """ + + return super().get_nonleader_users_in_areas() + + def get_nonplayer_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the hubbed game that are not players of the + hubbed game. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the hubbed game that are not players of the + hubbed game. + + """ + + return super().get_nonplayer_users_in_areas() + + def is_unmanaged(self): + """ + Return True if this hubbed game is unmanaged, False otherwise. + + Returns + ------- + bool + True if unmanaged, False otherwise. + + """ + + return super().is_unmanaged() + + def destroy(self): + """ + Mark this hubbed game as destroyed and notify its manager so that it is deleted. + If the hubbed game is already destroyed, this function does nothing. + A hubbed game marked for destruction will delete all of its timers, teams, remove all + its players and unsubscribe it from updates of its former players. + + This method is reentrant (it will do nothing though). + + Returns + ------- + None. + + """ + + self.unchecked_destroy() + self.manager._check_structure() + self._check_structure() # Manager will not check this otherwise. + + def unchecked_destroy(self): + """ + Mark this hubbed game as destroyed and notify its manager so that it is deleted. + If the hubbed game is already destroyed, this function does nothing. + + This method is reentrant (it will do nothing though). + + Returns + ------- + None. + + """ + super().unchecked_destroy() + + def _on_client_inbound_ms_check( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): + """ + Default callback for hubbed game player signaling it wants to check if sending an IC + message is appropriate. The IC arguments can be passed by reference, so this also serves as + an opportunity to modify the IC message if neeeded. + + To indicate a message should not be sent, some TsuserverException can be raised. The + message of the exception will be sent to the client. + + Parameters + ---------- + player : ClientManager.Client + Player that wants to send the IC message. + contents : Dict[str, Any], optional + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_client_inbound_ms_check(player, contents=contents) + + def _on_client_inbound_ms_final( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): + """ + Default callback for hubbed game player signaling it has sent an IC message. + This callback is executed after the server is done making all modifications to the MS packet + sent by the server. + + By default does nothing. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has sent an IC message. + contents : Dict[str, Any], optional + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_client_inbound_ms_final(player, contents=contents) + + def _on_client_change_character( + self, + player: ClientManager.Client, + old_char_id: int = -1, + old_char_name: str = '', + new_char_id: int = -1, + new_char_name: str = '', + ): + """ + Default callback for hubbed game player signaling it has changed character. + + By default it only checks if the player is now no longer having a participant character. + If that is the case and the hubbed game requires all players have participant characters, + the player is automatically removed. + + Note that it may not necessarily be the case that the following hold: + 1. `old_char_name == player.hub.character_manager.get_character_name(old_char_id)`. + 2. `new_char_name == player.hub.character_manager.get_character_name(new_char_id)`. + This can occur for example if the character list changes, which prompts the player to + change character. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has changed character. + old_char_id : int, optional + Previous character ID. The default is -1. + old_char_name : str, optional + Previous character name. The default is the empty string. + new_char_id : int, optional + New character ID. The default is -1. + new_char_name : int, optional + New character name. The default is the empty string. + + Returns + ------- + None. + + """ + + super()._on_client_change_character( + player, + old_char_id=old_char_id, + old_char_name=old_char_name, + new_char_id=new_char_id, + new_char_name=new_char_name, + ) + + def _on_client_destroyed(self, player: ClientManager.Client): + """ + Default callback for hubbed game player signaling it was destroyed, for example, as a + result of a disconnection. + + By default it only removes the player from the hubbed game. If the hubbed game is + already unmanaged or the player is not in the hubbed game, this callback does nothing. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it was destroyed. + + Returns + ------- + None. + + """ + + super()._on_client_destroyed(player) + + def _on_area_client_left_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False, + ): + """ + Default callback for hubbed game area signaling a client left. This is executed after + all other actions related to moving the player to a new area have been executed: + in particular, client.area holds the new area of the client. + + By default it removes the player from the hubbed game if their new area is not part of + the hubbed game. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has left. + client : ClientManager.Client, optional + The client that has left. The default is None. + new_area : AreaManager.Area + The new area the client has gone to. The default is None. + old_displayname : str, optional + The old displayed name of the client before they changed area. This will typically + change only if the client's character or showname are taken. The default is None. + ignore_bleeding : bool, optional + If the code should ignore actions regarding bleeding. The default is False. + ignore_autopass : bool, optional + If the code should ignore actions regarding autopass. The default is False. + + Returns + ------- + None. + + """ + + super()._on_area_client_left_final( + area, + client=client, + old_displayname=old_displayname, + ignore_bleeding=ignore_bleeding, + ignore_autopass=ignore_autopass, + ) + + def _on_area_client_entered_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_area: Union[AreaManager.Area, None] = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False, + ): + """ + Default callback for hubbed game area signaling a client entered. + + By default adds a user to the hubbed game if the hubbed game is meant to + automatically add users that enter an area part of the hubbed game. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has entered. + client : ClientManager.Client, optional + The client that has entered. The default is None. + old_area : AreaManager.Area + The old area the client has come from (possibly None for a newly connected user). The + default is None. + old_displayname : str, optional + The old displayed name of the client before they changed area. This will typically + change only if the client's character or showname are taken. The default is None. + ignore_bleeding : bool, optional + If the code should ignore actions regarding bleeding. The default is False. + ignore_autopass : bool, optional + If the code should ignore actions regarding autopass. The default is False. + + Returns + ------- + None. + + """ + + super()._on_area_client_entered_final( + area, + client=client, + old_area=old_area, + old_displayname=old_displayname, + ignore_bleeding=ignore_bleeding, + ignore_autopass=ignore_autopass, + ) + + def _on_area_client_inbound_ms_check( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + contents: Dict[str, Any] = None + ): + """ + Default callback for hubbed game area signaling a client in the area sent an IC message. + Unlike the ClientManager.Client callback for send_ic_check, this one is triggered + regardless of whether the sender is part of the hubbed game or not. This is useful for + example, to filter out messages sent by non-players. + + By default does nothing. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has entered. + client : ClientManager.Client, optional + The client that has send the IC message. The default is None. + contents : Dict[str, Any] + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_area_client_inbound_ms_check( + area, + client=client, + contents=contents, + ) + + def _on_area_destroyed(self, area: AreaManager.Area): + """ + Default callback for hubbed game area signaling it was destroyed. + + By default it calls self.unchecked_remove_area(area). + + Parameters + ---------- + area : AreaManager.Area + Area that signaled it was destroyed. + + Returns + ------- + None. + + """ + + super()._on_area_destroyed(area) + + def _on_areas_loaded(self, area_manager: AreaManager): + """ + Default callback for hub area manager signaling it loaded new areas. + + By default it does nothing. + + Parameters + ---------- + area_manager : AreaManager + AreaManager that signaled the areas load. + + Returns + ------- + None. + + """ + + pass + + +class _HubbedGame(_HubbedGameTrivialInherited): + """ + A hubbed game is a game that manages and subscribes to its areas' updates. + Any player of such a hubbed game must be in an area of the hubbed game. If a player of + the hubbed game goes to an area not part of the hubbed game, they are removed + automatically from the hubbed game. + + If an area is removed from the set of areas of the hubbed game, all players in that area + are removed in some unspecified order. + + Each of these hubbed games may also impose a concurrent area membership limit, so that + every area part of a hubbed game is at most an area of that many hubbed games managed + by this games's manager. + + Each of these hubbed games may also set an autoadd on client enter flag. If set, nonplayer + clients who enter an area part of the hubbed game will be added to the hubbed game if + possible; if this fails, no action is taken and no errors are propagated. + + Attributes + ---------- + server : TsuserverDR + Server the hubbed game belongs to. + manager : HubbedGameManager + Manager for this hubbed game. + hub: _Hub + Hub for this hubbed game. + listener : Listener + Standard listener of the hubbed game. + + Callback Methods + ---------------- + _on_area_client_left_final + Method to perform once a client left an area of the hubbed game. + _on_area_client_entered_final + Method to perform once a client entered an area of the hubbed game. + _on_area_destroyed + Method to perform once an area of the hubbed game is marked for destruction. + _on_client_inbound_ms_check + Method to perform once a player of the hubbed game wants to send an IC message. + _on_client_inbound_ms_final + Method to perform once a player of the hubbed game sends an IC message. + _on_client_change_character + Method to perform once a player of the hubbed game has changed character. + _on_client_destroyed + Method to perform once a player of the hubbed game is destroyed. + + """ + + # (Private) Attributes + # -------------------- + # _areas : Set[AreaManager.Area] + # Areas of the hubbed game. + # _area_concurrent_limit : Union[int, None] + # The maximum number of hubbed games managed by `manager` that any + # area of this hubbed game may belong to, including this hubbed game. + # _autoadd_on_client_enter : bool + # Whether nonplayer users that enter an area part of the hubbed game will be + # automatically added if permitted by the conditions of the hubbed game. + # + # Invariants + # ---------- + # 1. For each player of the hubbed game, they are in an area part of the hubbed game. + # 2. It is not true that the hubbed game requires invitations and automatically adds users + # that join an area part of the hubbed game. + # 3. The invariants from the parent class _Game are satisfied. + + def __init__( + self, + server: TsuserverDR, + manager: HubbedGameManager, + game_id: str, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + area_concurrent_limit: Union[int, None] = None, + autoadd_on_client_enter: bool = False, + require_areas: bool = True, + # new + hub: _Hub = None, + ): + """ + Create a new hubbed game. A hubbed game should not be fully initialized anywhere + else other than some manager code, as otherwise the manager will not recognize the + hubbed game. + + Parameters + ---------- + server : TsuserverDR + Server the hubbed game belongs to. + manager : HubbedGameManager + Manager for this hubbed game. + game_id : str + Identifier of the hubbed game. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the hubbed game supports. If None, it + indicates the hubbed game has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of hubbed games managed by `manager` that any + player of this hubbed game may belong to, including this hubbed game. If None, + it indicates that this hubbed game does not care about how many other games with + areas managed by `manager` each of its players belongs to. Defaults to None. + require_invitation : bool, optional + If True, players can only be added to the hubbed game if they were previously + invited. If False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the hubbed game has no players left, the hubbed game + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + require_leaders : bool, optional + If True, if at any point the hubbed game has no leaders left, the hubbed game + will choose a leader among any remaining players left; if no players are left, the next + player added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + hubbed game, and players that switch to something other than a participant character + will be automatically removed from the hubbed game. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the hubbed game supports. If None, it + indicates the hubbed game has no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the hubbed game supports. If None, it + indicates the hubbed game has no timer limit. Defaults to None. + area_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of hubbed games managed by `manager` that any + area of this hubbed game may belong to, including this hubbed game. If None, it + indicates that this hubbed game does not care about how many other hubbed game + managed by `manager` each of its areas belongs to. Defaults to 1 (an area may not be a + part of another hubbed game managed by `manager` while being an area of this game). + autoadd_on_client_enter : bool, optional + If True, nonplayer users that enter an area part of the hubbed game will be + automatically added if permitted by the conditions of the hubbed game. If False, no + such adding will take place. Defaults to False. + require_areas : bool, optional + If True, if at any point the hubbed game has no areas left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + hub : _Hub, optional + Hub the hubbed game belongs to. Defaults to None. + + """ + + self.hub = hub + + super().__init__( + server, + manager, + game_id, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + require_areas=require_areas, + ) + + self.listener.subscribe(self.hub.area_manager) + self.listener.update_events({ + 'areas_loaded': self._on_areas_loaded, + }) + self.manager: HubbedGameManager # Setting for typing + + def get_type_name(self) -> str: + """ + Return the type name of the hubbed game. Names are fully lowercase. + Implementations of the class should replace this with a human readable name of the hubbed + game. + + Returns + ------- + str + Type name of the hubbed game. + + """ + + return "hubbed game" + + def unchecked_add_area(self, area: AreaManager.Area): + """ + Add an area to this hubbed game's set of areas. + + This method does not assert structural integrity. + + Parameters + ---------- + area : AreaManager.Area + Area to add. + + Raises + ------ + HubbedGameError.GameIsUnmanagedError + If the hubbed game was scheduled for deletion and thus does not accept any mutator + public method calls. + HubbedGameError.AreaAlreadyInGameError + If the area is already part of the hubbed game. + HubbedGameError.AreaHitGameConcurrentLimitError. + If `area` has reached the concurrent area membership limit of any of the games with + areas it belongs to managed by this manager, or by virtue of adding this area it will + violate this hubbed game's concurrent area membership limit. + + """ + + if self.is_unmanaged(): + raise HubbedGameError.GameIsUnmanagedError + if area.hub != self.hub: + raise HubbedGameError.AreaNotInHubError + + try: + return super().unchecked_add_area(area) + except GameWithAreasError.GameIsUnmanagedError: + raise RuntimeError(self) + except GameWithAreasError.AreaAlreadyInGameError: + raise HubbedGameError.AreaAlreadyInGameError + except GameWithAreasError.AreaHitGameConcurrentLimitError: + raise HubbedGameError.AreaHitGameConcurrentLimitError + + def _check_structure(self): + """ + Assert that all invariants specified in the class description are maintained. + + Raises + ------ + AssertionError + If any of the invariants are not maintained. + + """ + + # 1. + for area in self.get_areas(): + assert area.hub == self.hub, ( + f'For hubbed game {self}, expected all its areas belong to hub {self.hub}, ' + f'found area {area} belonged to hub {area.hub} instead' + ) + + # 2. + super()._check_structure() + + def __str__(self) -> str: + """ + Return a string representation of this hubbed game. + + Returns + ------- + str + Representation. + + """ + + return (f"HubbedGame::{self.get_id()}:{self.hub}" + f"{self.get_players()}:{self.get_leaders()}:{self.get_invitations()}" + f"{self.get_timers()}:" + f"{self.get_teams()}:" + f"{self.get_areas()}") + + def __repr__(self) -> str: + """ + Return a representation of this hubbed game. + + Returns + ------- + str + Printable representation. + + """ + + return (f'GameWithAreas(server, {self.hub}, {self.manager.get_id()}, "{self.get_id()}", ' + f'player_limit={self.get_player_limit()}, ' + f'player_concurrent_limit={self.get_player_concurrent_limit()}, ' + f'require_players={self.requires_players()}, ' + f'require_invitations={self.requires_invitations()}, ' + f'require_leaders={self.requires_leaders()}, ' + f'require_participant_character={self.requires_participant_characters()}, ' + f'team_limit={self.get_team_limit()}, ' + f'timer_limit={self.get_timer_limit()}, ' + f'areas={self.get_areas()}), ' + f'|| ' + f'players={self.get_players()}, ' + f'invitations={self.get_invitations()}, ' + f'leaders={self.get_leaders()}, ' + f'timers={self.get_timers()}, ' + f'teams={self.get_teams()}, ' + f'unmanaged={self.is_unmanaged()}), ' + f')') + +class _HubbedGameManagerTrivialInherited(GameWithAreasManager): + """ + This class should not be instantiated. + """ + + def new_managee( + self, + managee_type: Type[_HubbedGame] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + areas: Set[AreaManager.Area] = None, + area_concurrent_limit: Union[int, None] = None, + autoadd_on_client_enter: bool = False, + autoadd_on_creation_existing_users: bool = False, + require_areas: bool = True, + hub: Union[_Hub, None] = None, + **kwargs: Any, + ) -> _HubbedGame: + """ + Create a new hubbed game managed by this manager. + + Parameters + ---------- + managee_type : Type[_HubbedGame], optional + Class of hubbed game that will be produced. Defaults to None (and converted to the + default hubbed game created by this hubbed game manager). + creator : Union[ClientManager.Client, None], optional + The player who created this hubbed game. If set, they will also be added to the + hubbed game. Defaults to None. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the hubbed game supports. If None, it + indicates the hubbed game has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of hubbed games managed by `self` that any + player of this hubbed game to create may belong to, including this hubbed game + to create. If None, it indicates that this hubbed game does not care about how many + other hubbed games managed by `self` each of its players belongs to. Defaults to 1 + (a player may not be in another game managed by `self` while in this game). + require_invitations : bool, optional + If True, users can only be added to the hubbed game if they were previously invited. + If False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the hubbed game loses all its players, the hubbed game + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + require_leaders : bool, optional + If True, if at any point the hubbed game has no leaders left, the hubbed game + will choose a leader among any remaining players left; if no players are left, the next + player added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + hubbed game, and players that switch to something other than a participant character + will be automatically removed from the hubbed game. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the hubbed game will support. If None, + it indicates the hubbed game will have no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the hubbed game will support. If None, + it indicates the hubbed game will have no timer limit. Defaults to None. + areas : Set[AreaManager.Area], optional + The areas to add to the hubbed game when creating it. Defaults to None (and + converted to a set containing the creator's area if given a creator, and the empty set + otherwise). + area_concurrent_limit : Union[int, None] + The concurrent area membership limit of this hubbed game. Defaults to None. + autoadd_on_client_enter : bool + If the hubbed game will always attempt to add nonplayer users who enter an area + part of the hubbed game. Defaults to False. + autoadd_on_creation_existing_users : bool + If the hubbed game will attempt to add nonplayer users who were in an area added + to the hubbed game on creation. Defaults to False. + require_areas : bool, optional + If True, if at any point the hubbed game has no areas left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + hub : _Hub, optional + Hub of the hubbed game. Defaults to None (and converted to the creator's hub if given a + creator, and None otherwise). + **kwargs : Any + Additional arguments to consider when producing the hubbed game. + + Returns + ------- + _HubbedGame + The created hubbed game. + + Raises + ------ + HubbedGameError.ManagerTooManyGamesError + If the manager is already managing its maximum number of games. + Any error from the created hubbed game's add_player(creator) + If the hubbed game cannot add `creator` as a player if given one. + + """ + + game = self.unchecked_new_managee( + managee_type=managee_type, + creator=creator, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + # kwargs + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + areas=areas, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + autoadd_on_creation_existing_users=autoadd_on_creation_existing_users, + require_areas=require_areas, + hub=hub, + **kwargs, + ) + self._check_structure() + return game + + def unchecked_new_managee( + self, + managee_type: Type[_HubbedGame] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + areas: Set[AreaManager.Area] = None, + area_concurrent_limit: Union[int, None] = None, + autoadd_on_client_enter: bool = False, + autoadd_on_creation_existing_users: bool = False, + require_areas: bool = True, + hub: Union[_Hub, None] = None, + **kwargs: Any, + ) -> _HubbedGame: + + """ + Create a new hubbed game managed by this manager. + + This method does not assert structural integrity. + + Parameters + ---------- + managee_type : Type[_HubbedGame], optional + Class of hubbed game that will be produced. Defaults to None (and converted to the + default hubbed game created by this hubbed game manager). + creator : Union[ClientManager.Client, None], optional + The player who created this hubbed game. If set, they will also be added to the + hubbed game. Defaults to None. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the hubbed game supports. If None, it + indicates the hubbed game has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of hubbed games managed by `self` that any + player of this hubbed game to create may belong to, including this hubbed game + to create. If None, it indicates that this hubbed game does not care about how many + other hubbed games managed by `self` each of its players belongs to. Defaults to 1 + (a player may not be in another game managed by `self` while in this game). + require_invitations : bool, optional + If True, users can only be added to the hubbed game if they were previously invited. + If False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the hubbed game loses all its players, the hubbed game + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + require_leaders : bool, optional + If True, if at any point the hubbed game has no leaders left, the hubbed game + will choose a leader among any remaining players left; if no players are left, the next + player added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + hubbed game, and players that switch to something other than a participant character + will be automatically removed from the hubbed game. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the hubbed game will support. If None, + it indicates the hubbed game will have no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the hubbed game will support. If None, + it indicates the hubbed game will have no timer limit. Defaults to None. + areas : Set[AreaManager.Area], optional + The areas to add to the hubbed game when creating it. Defaults to None (and + converted to a set containing the creator's area if given a creator, and the empty set + otherwise). + area_concurrent_limit : Union[int, None] + The concurrent area membership limit of this hubbed game. Defaults to None. + autoadd_on_client_enter: bool, optional + If the hubbed game will always attempt to add nonplayer users who enter an area + part of the hubbed game. Defaults to False. + autoadd_on_creation_existing_users : bool, optional + If the hubbed game will attempt to add nonplayer users who were in an area added + to the hubbed game on creation. Defaults to False. + require_areas : bool, optional + If True, if at any point the hubbed game has no areas left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + hub : _Hub, optional + Hub of the hubbed game. Defaults to None (and converted to the creator's hub if given a + creator, and None otherwise). + **kwargs : Any + Additional arguments to consider when producing the hubbed game. + + Returns + ------- + _HubbedGame + The created hubbed game. + + Raises + ------ + HubbedGameError.ManagerTooManyGamesError + If the manager is already managing its maximum number of games. + Any error from the created hubbed game's add_player(creator) + If the hubbed game cannot add `creator` as a player if given one. + + """ + + if managee_type is None: + managee_type = self.get_managee_type() + if not hub: + hub = creator.hub if creator else None + + try: + return super().unchecked_new_managee( + managee_type=managee_type, + creator=creator, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + areas=areas, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + autoadd_on_creation_existing_users=autoadd_on_creation_existing_users, + require_areas=require_areas, + # kwargs + hub=hub, + **kwargs, + ) + except GameWithAreasError.ManagerTooManyGamesError: + raise HubbedGameError.ManagerTooManyGamesError + + def get_managee_type(self) -> Type[_HubbedGame]: + """ + Return the type of the hubbed game that will be constructed by default with a call of + `new_managee`. + + Returns + ------- + Type[_HubbedGame] + Type of the hubbed game. + + """ + + return super().get_managee_type() + + def delete_managee(self, managee: _HubbedGame) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a hubbed game managed by this manager, so all its players no longer belong to + this hubbed game. + + Parameters + ---------- + managee : _HubbedGame + The hubbed game to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the hubbed game that was deleted. + + Raises + ------ + HubbedGameError.ManagerDoesNotManageGameError + If the manager does not manage the target hubbed game. + + """ + + game_id, game_players = self.unchecked_delete_managee(managee) + self._check_structure() + return game_id, game_players + + def unchecked_delete_managee( + self, + managee: _HubbedGame + ) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a hubbed game managed by this manager, so all its players no longer belong to + this hubbed game. + + Parameters + ---------- + managee : _HubbedGame + The hubbed game to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the hubbed game that was deleted. + + Raises + ------ + HubbedGameError.ManagerDoesNotManageGameError + If the manager does not manage the target hubbed game. + + """ + + try: + return super().unchecked_delete_managee(managee) + except GameWithAreasError.ManagerDoesNotManageGameError: + raise HubbedGameError.ManagerDoesNotManageGameError + + def manages_managee(self, game: _HubbedGame): + """ + Return True if the hubbed game is managed by this manager, False otherwise. + + Parameters + ---------- + game : _HubbedGame + The game to check. + + Returns + ------- + bool + True if the manager manages this hubbed game, False otherwise. + + """ + + return super().manages_managee(game) + + def get_managees(self) -> Set[_HubbedGame]: + """ + Return (a shallow copy of) the hubbed games this manager manages. + + Returns + ------- + Set[_HubbedGame] + Hubbed games this manager manages. + + """ + + return super().get_managees() + + def get_managee_by_id(self, managee_id: str) -> _HubbedGame: + """ + If `managee_id` is the ID of a hubbed game managed by this manager, return that. + + Parameters + ---------- + managee_id : str + ID of the hubbed game this manager manages. + + Returns + ------- + _HubbedGame + The hubbed game with that ID. + + Raises + ------ + HubbedGameError.ManagerInvalidGameIDError + If `game_id` is not the ID of a hubbed game this manager manages. + + """ + + try: + return super().get_managee_by_id(managee_id) + except GameWithAreasError.ManagerInvalidGameIDError: + raise HubbedGameError.ManagerInvalidGameIDError + + def get_managee_by_numerical_id(self, managee_numerical_id: int) -> _HubbedGame: + """ + If `managee_numerical_id` is the numerical ID of a hubbed game managed by this manager, + return the hubbed game. + + Parameters + ---------- + managee_numerical_id : int + Numerical ID of the hubbed game this manager manages. + + Returns + ------- + _HubbedGame + The hubbed game with that ID. + + Raises + ------ + HubbedGameError.ManagerInvalidGameIDError: + If `managee_numerical_id` is not the numerical ID of a hubbed game + this manager manages. + + """ + + try: + return super().get_managee_by_numerical_id(managee_numerical_id) + except GameWithAreasError.ManagerInvalidGameIDError: + raise HubbedGameError.ManagerInvalidGameIDError + + def get_managee_limit(self) -> Union[int, None]: + """ + Return the hubbed game limit of this manager. + + Returns + ------- + Union[int, None] + Hubbed game limit. + + """ + + return super().get_managee_limit() + + def get_managee_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all hubbed games managed by this manager. + + Returns + ------- + Set[str] + The IDs of all managed hubbed games. + + """ + + return super().get_managee_ids() + + def get_managee_ids_to_managees(self) -> Dict[str, _HubbedGame]: + """ + Return a mapping of the IDs of all hubbed games managed by this manager to their + associated hubbed game. + + Returns + ------- + Dict[str, _HubbedGame] + Mapping. + """ + + return super().get_managee_ids_to_managees() + + def get_managee_numerical_ids_to_managees(self) -> Dict[int, _HubbedGame]: + """ + Return a mapping of the numerical IDs of all hubbed game managed by this manager to + their associated hubbed game. + + Returns + ------- + Dict[int, _HubbedGame] + Mapping. + """ + + return super().get_managee_numerical_ids_to_managees() + + def get_managees_of_user(self, user: ClientManager.Client): + """ + Return (a shallow copy of) the hubbed games managed by this manager user `user` is a + player of. If the user is part of no such hubbed game, an empty set is returned. + + Parameters + ---------- + user : ClientManager.Client + User whose hubbed games will be returned. + + Returns + ------- + Set[_HubbedGame] + Hubbed games the player belongs to. + + """ + + return super().get_managees_of_user(user) + + def get_player_to_managees_map(self) -> Dict[ClientManager.Client, Set[_HubbedGame]]: + """ + Return a mapping of the players part of any hubbed game managed by this manager to the + hubbed game managed by this manager such players belong to. + + Returns + ------- + Dict[ClientManager.Client, Set[_HubbedGame]] + Mapping. + """ + + return super().get_player_to_managees_map() + + def get_users_in_some_managee(self) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) all the users that are part of some hubbed game managed by + this manager. + + Returns + ------- + Set[ClientManager.Client] + Users in some managed hubbed game. + + """ + + return super().get_users_in_some_managee() + + def is_managee_creatable(self) -> bool: + """ + Return whether a new hubbed game can currently be created without creating one. + + Returns + ------- + bool + True if a hubbed game can be currently created, False otherwise. + """ + + return super().is_managee_creatable() + + def get_id(self) -> str: + """ + Return the ID of this manager. This ID is guaranteed to be unique among + simultaneously existing Python objects. + + Returns + ------- + str + ID. + + """ + + return super().get_id() + + def get_managees_in_area(self, area: AreaManager.Area) -> Set[_HubbedGame]: + """ + Return (a shallow copy of) all hubbed games managed by this manager that contain + the given area. + + Parameters + ---------- + area : AreaManager.Area + Area that all returned hubbed games must contain. + + Returns + ------- + Set[_HubbedGame] + Hubbed games that contain the given area. + + """ + + return super().get_managees_in_area(area) + + def find_area_concurrent_limiting_managee( + self, + area: AreaManager.Area + ) -> Union[_HubbedGame, None]: + """ + For area `area`, find a hubbed game `most_restrictive_game` managed by this manager + such that, if `area` were to be added to another hubbed game managed by this manager, + they would violate `most_restrictive_game`'s concurrent area membership limit. + If no such hubbed game exists (or the area is not an area of any hubbed game + managed by this manager), return None. + If multiple such hubbed games exist, any one of them may be returned. + + Parameters + ---------- + area : AreaManager.Area + Area to test. + + Returns + ------- + Union[_HubbedGame, None] + Limiting hubbed game as previously described if it exists, None otherwise. + + """ + + return super().find_area_concurrent_limiting_managee(area) + + def find_player_concurrent_limiting_managee( + self, + user: ClientManager.Client + ) -> Union[_HubbedGame, None]: + """ + For user `user`, find a hubbed game `most_restrictive_game` managed by this manager such + that, if `user` were to join another hubbed game managed by this manager, they would + violate `most_restrictive_game`'s concurrent player membership limit. + If no such hubbed game exists (or the player is not member of any hubbed game + managed by this manager), return None. + If multiple such hubbed games exist, any one of them may be returned. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Returns + ------- + Union[_HubbedGame, None] + Limiting hubbed game as previously described if it exists, None otherwise. + + """ + + return super().find_player_concurrent_limiting_managee(user) + + def get_areas_to_managees_map(self) -> Dict[ClientManager.Client, Set[_HubbedGame]]: + """ + Return a mapping of the areas part of any hubbed game managed by this manager to the + hubbed game managed by this manager such players belong to. + + Returns + ------- + Dict[ClientManager.Client, Set[_HubbedGame]] + Mapping. + """ + + return super().get_areas_to_managees_map() + +class HubbedGameManager(_HubbedGameManagerTrivialInherited): + """ + A hubbed game manager is a game manager with dedicated area management functions. + + Attributes + ---------- + server : TsuserverDR + Server the hubbed game manager belongs to. + """ + + # Invariants + # ---------- + # 1. The invariants of the parent class are maintained. + + def __init__( + self, + server: TsuserverDR, + managee_limit: Union[int, None] = None, + default_managee_type: Type[_HubbedGame] = None, + ): + """ + Create a hubbed game manager object. + + Parameters + ---------- + server : TsuserverDR + The server this hubbed game manager belongs to. + managee_limit : int, optional + The maximum number of hubbed games this manager can handle. Defaults to None + (no limit). + default_managee_type : Type[_HubbedGame], optional + The default type of hubbed game this manager will create. Defaults to None (and then + converted to _HubbedGame). + + """ + + if default_managee_type is None: + default_managee_type = _HubbedGame + + super().__init__( + server, + managee_limit=managee_limit, + default_managee_type=default_managee_type + ) + + def get_available_managee_id(self): + """ + Get a hubbed game ID that no other hubbed game managed by this manager has. + + Returns + ------- + str + A unique hubbed game ID. + + Raises + ------ + HubbedGameError.ManagerTooManyGamesError + If the manager is already managing its maximum number of games. + + """ + + game_number = 0 + game_limit = self.get_managee_limit() + while game_limit is None or game_number < game_limit: + new_game_id = f'hg{game_number}' + if new_game_id not in self.get_managee_ids(): + return new_game_id + game_number += 1 + raise HubbedGameError.ManagerTooManyGamesError + + def _check_structure(self): + """ + Assert that all invariants specified in the class description are maintained. + + Raises + ------ + AssertionError + If any of the invariants are not maintained. + + """ + + super()._check_structure() + + def __repr__(self) -> str: + """ + Return a representation of this hubbed game manager. + + Returns + ------- + str + Printable representation. + + """ + + return (f"HubbedGameManager(server, managee_limit={self.get_managee_limit()}, " + f"default_managee_type={self.get_managee_type()}, " + f"|| " + f"_id_to_managee={self.get_managee_ids_to_managees()}, " + f"_user_to_managees={self.get_player_to_managees_map()}, " + f"_area_to_managees={self.get_areas_to_managees_map()}, " + f"id={self.get_id()}, " + f')') diff --git a/server/logger.py b/server/logger.py index 3db57d8d9..962ee7958 100644 --- a/server/logger.py +++ b/server/logger.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -30,6 +31,7 @@ from server.constants import Constants if typing.TYPE_CHECKING: + from server.client_manager import ClientManager from server.tsuserver import TsuserverDR def setup_logger(debug): @@ -72,18 +74,145 @@ def setup_logger(debug): return (debug_log, debug_handler), (server_log, server_handler) -def log_debug(msg, client=None): +def log_debug(msg: str, client: Union[ClientManager.Client, None] = None): msg = parse_client_info(client) + msg logging.getLogger('debug').debug(msg) -def log_error(msg, server: Union[TsuserverDR, None], errortype='P') -> str: +def _print_exception(etype, evalue, etraceback): + return f'\n{"".join(traceback.format_exception(etype, evalue, etraceback))}' + +def _log_error(server: TsuserverDR) -> str: + msg = '' + + # Add list of most recent packets + msg += f'\n\n\n= {server.logged_packet_limit} most recent packets dump =' + if not server.logged_packets: + msg += '\nNo logged packets.' + else: + for logged_packet in server.logged_packets: + str_logged_packet = ' '.join(logged_packet) + msg += f'\n{str_logged_packet}' + + # Add list of clients to error log + try: + msg += '\n\n\n= Dump of clients =' + msg += f'\n*Number of clients: {len(server.get_clients())}' + + msg += '\n*Current clients:' + clients = sorted(server.get_clients(), key=lambda c: c.id) + + for c in clients: + try: + msg += f'\n\n{c.get_info(as_mod=True)}' + except Exception: + etype, evalue, etraceback = sys.exc_info() + msg += f'\n\nError generating dump of client {c.id}.' + msg += _print_exception(etype, evalue, etraceback) + except Exception: + etype, evalue, etraceback = sys.exc_info() + msg += '\nError generating dump of clients.' + msg += _print_exception(etype, evalue, etraceback) + + + # Add list of hubs to error log + try: + msg += '\n\n\n= Dump of hubs =' + msg += f'\n*Number of hubs: {len(server.hub_manager.get_managees())}' + + msg += '\n*Current hubs:' + hubs = sorted(server.hub_manager.get_managees(), + key=lambda hub: hub.get_id()) + + for hub in hubs: + msg += f'\n\n== Hub {hub.get_id()} ==' + + try: + msg += '\n\n=== Area list ===' + try: + msg += (f'\n*Current area list file: ' + f'{hub.area_manager.get_source_file()}') + msg += (f'\n*Previous area list file: ' + f'{hub.area_manager.get_previous_source_file()}') + + msg += '\n*Current area list:' + for area in hub.area_manager.get_areas(): + msg += f'\n**{area}' + for c in area.clients: + msg += f'\n***{c}' + except Exception: + etype, evalue, etraceback = sys.exc_info() + msg += f'\nError generating dump of area list for hub {hub.get_id()}.' + msg += _print_exception(etype, evalue, etraceback) + + msg += '\n\n=== Background list ===' + try: + msg += (f'\n*Current background list file: ' + f'{hub.background_manager.get_source_file()}') + msg += (f'\n*Previous background list file: ' + f'{hub.background_manager.get_previous_source_file()}') + + msg += '\n*Current background list:' + for (i, background) in enumerate(hub.background_manager.get_backgrounds()): + msg += f'\n**{i}: {background}' + except Exception: + etype, evalue, etraceback = sys.exc_info() + msg += f'\nError generating dump of background list for hub {hub.get_id()}.' + msg += _print_exception(etype, evalue, etraceback) + + msg += '\n\n=== Character list ===' + try: + msg += (f'\n*Current character list file: ' + f'{hub.character_manager.get_source_file()}') + msg += (f'\n*Previous character list file: ' + f'{hub.character_manager.get_previous_source_file()}') + + msg += '\n*Current character list:' + for (i, character) in enumerate(hub.character_manager.get_characters()): + msg += f'\n**{i}: {character}' + except Exception: + etype, evalue, etraceback = sys.exc_info() + msg += f'\nError generating dump of character list for hub {hub.get_id()}.' + msg += _print_exception(etype, evalue, etraceback) + + msg += '\n\n=== DJ list ===' + try: + msg += (f'\n*Current DJ list file: ' + f'{hub.music_manager.get_source_file()}') + msg += (f'\n*Previous DJ list file: ' + f'{hub.music_manager.get_previous_source_file()}') + + msg += '\n*Current music:' + for (i, category_songs) in enumerate(hub.music_manager.get_music()): + category, songs = category_songs['category'], category_songs['songs'] + msg += f'\n**{i}: {category}' + for (j, song) in enumerate(songs): + msg += f'\n***{j}: {song}' + except Exception: + etype, evalue, etraceback = sys.exc_info() + msg += f'\nError generating dump of DJ list for hub {hub.get_id()}.' + msg += _print_exception(etype, evalue, etraceback) + + except Exception: + etype, evalue, etraceback = sys.exc_info() + msg += f'\nError generating dump of hub {hub.get_id()}.' + msg += _print_exception(etype, evalue, etraceback) + + + except Exception: + etype, evalue, etraceback = sys.exc_info() + msg += '\nError generating dump of hubs.' + msg += _print_exception(etype, evalue, etraceback) + + return msg + +def log_error(msg: str, server: Union[TsuserverDR, None], errortype='P') -> str: # errortype "C" if server raised an error as a result of a client packet. # errortype "D" if player manually requested an error dump # errortype "P" if server raised an error for any other reason error_log = logging.getLogger('error') - file = 'logs/{}{}.log'.format(Constants.get_time_iso(), errortype) + file = f'logs/{Constants.get_time_iso()}{errortype}.log' file = file.replace(':', '') error_handler = logging.FileHandler(file, encoding='utf-8') @@ -92,43 +221,7 @@ def log_error(msg, server: Union[TsuserverDR, None], errortype='P') -> str: error_log.addHandler(error_handler) if server: - # Add list of most recent packets - msg += f'\n\n\n= {server.logged_packet_limit} most recent packets dump =' - if not server.logged_packets: - msg += '\nNo logged packets.' - else: - for logged_packet in server.logged_packets: - str_logged_packet = ' '.join(logged_packet) - msg += f'\n{str_logged_packet}' - - # Add list of clients to error log - try: - msg += '\n\n\n= Client dump. =' - msg += '\n*Number of clients: {}'.format(len(server.get_clients())) - msg += '\n*Current clients' - clients = sorted(server.get_clients(), key=lambda c: c.id) - for c in clients: - msg += '\n\n{}'.format(c.get_info(as_mod=True)) - except Exception: - etype, evalue, etraceback = sys.exc_info() - msg += '\nError generating client dump.' - msg += '\n{}'.format("".join(traceback.format_exception(etype, evalue, etraceback))) - - # Add list of areas to error log - try: - msg += '\n\n\n= Area dump =' - msg += '\n*Current area list: {}'.format(server.area_manager.get_source_file()) - msg += '\n*Old area list: {}'.format(server.old_area_list) - msg += '\n*Current areas:' - - for area in server.area_manager.get_areas(): - msg += '\n**{}'.format(area) - for c in area.clients: - msg += '\n***{}'.format(c) - except Exception: - etype, evalue, etraceback = sys.exc_info() - msg += '\nError generating area dump.' - msg += '\n{}'.format("".join(traceback.format_exception(etype, evalue, etraceback))) + msg += _log_error(server) else: # Case server was not initialized properly, so areas and clients are not set msg += ('\nServer was not initialized, so packet, client and area dumps could not be ' @@ -147,22 +240,12 @@ def log_server(msg, client=None): logging.getLogger('server').info(msg) -def log_server2(msg, client=None): - # Empty for test.py purposes - pass - - def log_print(msg, client=None): msg = f'{parse_client_info(client)}{msg}' current_time = Constants.get_time_iso() print('{}: {}'.format(current_time, msg)) -def log_print2(msg, client=None): - # Empty for test.py purposes - pass - - def log_pdebug(msg, client=None): log_debug(msg, client=client) log_print(msg, client=client) diff --git a/server/music_manager.py b/server/music_manager.py index 3ad40fc62..92c5f40be 100644 --- a/server/music_manager.py +++ b/server/music_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -27,6 +28,7 @@ from server.validate.music import ValidateMusic if typing.TYPE_CHECKING: + from server.hub_manager import _Hub from server.tsuserver import TsuserverDR class MusicManager(AssetManager): @@ -35,7 +37,7 @@ class MusicManager(AssetManager): loaded file or an adequate Python representation. """ - def __init__(self, server: TsuserverDR): + def __init__(self, server: TsuserverDR, hub: Union[_Hub, None] = None): """ Create a music manager object. @@ -43,13 +45,16 @@ def __init__(self, server: TsuserverDR): ---------- server: TsuserverDR The server this music manager belongs to. + hub : _Hub, optional + The hub this music manager belongs to. Defaults to None. """ - super().__init__(server) + super().__init__(server, hub=hub) self._music = [] - self._source_file = 'config/music.yaml' + self._source_file = None + self._previous_source_file = None - def get_name(self) -> str: + def get_type_name(self) -> str: """ Return `'music list'`. @@ -75,12 +80,12 @@ def get_default_file(self) -> str: def get_loader(self) -> Callable[[str, ], str]: """ - Return `self.server.load_characters`. + Return `self.load_file`. Returns ------- Callable[[str, ], str] - `self.server.load_characters`. + `self.load_file`. """ return self.load_file @@ -110,6 +115,20 @@ def get_source_file(self) -> Union[str, None]: return self._source_file + def get_previous_source_file(self) -> Union[str, None]: + """ + Return the output that self.get_source_file() would have returned *before* the last + successful time a music list was successfully loaded. + If no such call was ever made, return None. + + Returns + ------- + Union[str, None] + Previous source file or None. + """ + + return self._previous_source_file + def get_custom_folder(self) -> str: """ Return `'config/music_lists'`. @@ -203,7 +222,10 @@ def transfer_contents_from_manager(self, other: MusicManager) -> List[Dict[str, return output - def _load_music(self, new_list: List[Dict[str, Any]], source_file: Union[str, None]) -> List[Dict[str, Any]]: + def _load_music(self, new_list: List[Dict[str, Any]], + source_file: Union[str, None]) -> List[Dict[str, Any]]: + self._previous_source_file = self._source_file + self._music = new_list.copy() self._source_file = source_file @@ -229,6 +251,15 @@ def is_music(self, music: str) -> bool: return False def get_client_view(self) -> List[str]: + """ + Return the list of music of the music manager in a format a client can understand. + + Returns + ------- + List[str] + List of music. + """ + prepared_music_list = list() for item in self._music: category = item['category'] @@ -253,3 +284,9 @@ def _check_structure(self): # At least one music track assert self._music + + +class PersonalMusicManager(MusicManager): + def __init__(self, server: TsuserverDR, hub: Union[_Hub, None] = None): + super().__init__(server, hub) + self.if_default_show_hub_music = True diff --git a/server/network/ao_commands.py b/server/network/ao_commands.py index fef9de38f..570c84cb7 100644 --- a/server/network/ao_commands.py +++ b/server/network/ao_commands.py @@ -1,4 +1,5 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) # Current project leader: 2018-21 Chrezm/Iuvee @@ -26,12 +27,13 @@ import re import time import typing - from typing import Any, Dict -from server import logger, clients +from server import clients, logger from server.constants import Constants -from server.exceptions import AreaError, ClientError, ServerError, PartyError, TsuserverException +from server.exceptions import (AreaError, ClientError, HubError, MusicError, + PartyError, ServerError, TsuserverException) + # from server.evidence import EvidenceList if typing.TYPE_CHECKING: @@ -65,10 +67,12 @@ def net_cmd_hi(client: ClientManager.Client, pargs: Dict[str, Any]): # Check if the client is banned for ipid in client.server.hdid_list[client.hdid]: if client.server.ban_manager.is_banned(ipid): + logger.log_server(f'Disconnected previously banned player who just tried to join. ' + f'Banned IPID: {ipid}', client) client.send_ooc_others( f'Banned client with HDID {client.hdid} and IPID {client.ipid} ' f'attempted to join the server but was refused entrance.', - is_officer=True) + is_officer=True, in_hub=None) client.send_command_dict('BD', dict()) client.disconnect() return @@ -106,30 +110,23 @@ def check_client_version(): version_list = raw_version.split('.') # Identify version number - if len(version_list) >= 3: - # Such versions include DRO and AO - release = int(version_list[0]) - major = int(version_list[1]) - # Strip out any extra identifiers (like -b1) from minor - match = re.match(r'(?P\d+)(?P.*)', version_list[2]) - if match: - minor = int(match['minor']) - rest = match['rest'] - else: - minor = 0 - rest = version_list[2] - if pargs['client_software'] not in ['DRO', 'AO2']: - return False + if len(version_list) < 3: + return False + + # Such versions include DRO and AO + if pargs['client_software'] not in ['DRO', 'AO2']: + return False + + release = int(version_list[0]) + major = int(version_list[1]) + # Strip out any extra identifiers (like -b1) from minor + match = re.match(r'(?P\d+)(?P.*)', version_list[2]) + if match: + minor = int(match['minor']) + rest = match['rest'] else: - # Only such version recognized now is CC - # CC has args[1] == 'CC - Update (\d+\.)*\d+' - if pargs['client_software_version'].startswith('CC'): - release = 'CC' - major = float(raw_version.split(' ')[-1]) - minor = 0 - rest = '' - else: - return False + minor = 0 + rest = version_list[2] # While we grab rest for the sake of the future-proofing, right now it is not used. # I added this useless if so my IDE wouldn't complain of an unused variable. @@ -137,49 +134,42 @@ def check_client_version(): pass if software == 'DRO': - if major >= 2: - if minor >= 2: - client.packet_handler = clients.ClientDRO1d2d2() + if release >= 2: + # DRO 2??? + # Placeholder + client.packet_handler = clients.ClientDRO1d2d2() + elif release >= 1: + if major >= 2: + if minor >= 2: + client.packet_handler = clients.ClientDRO1d2d2() + else: + client.packet_handler = clients.ClientDRO1d2d0() + elif major >= 1: + client.packet_handler = clients.ClientDRO1d1d0() else: - client.packet_handler = clients.ClientDRO1d2d0() - elif major >= 1: - client.packet_handler = clients.ClientDRO1d1d0() + client.packet_handler = clients.ClientDRO1d0d0() else: - client.packet_handler = clients.ClientDRO1d0d0() - else: # AO2 protocol + return False + elif software == 'AO2': # AO2 protocol if release == 2: if major >= 10: client.packet_handler = clients.ClientAO2d10() - elif major >= 9: - client.packet_handler = clients.ClientAO2d9d0() - elif major >= 8 and minor >= 4: - client.packet_handler = clients.ClientAO2d8d4() - elif major >= 8: # KFO - client.packet_handler = clients.ClientKFO2d8() - elif major == 7: # AO 2.7 - client.packet_handler = clients.ClientAO2d7() - elif major == 6: # AO 2.6 - client.packet_handler = clients.ClientAO2d6() - elif major == 4 and minor == 8: # Older DRO - client.packet_handler = clients.ClientDROLegacy() - else: - return False # Unrecognized - elif release == 'CC': - if major >= 24: - client.packet_handler = clients.ClientCC24() - elif major >= 22: - client.packet_handler = clients.ClientCC22() else: return False # Unrecognized + else: + return False # Unrecognized + else: + raise RuntimeError(f'{software}') + # The only way to make it here is if we have not returned False # If that is the case, we have successfully found a version return True if not check_client_version(): - # Warn player they are using an unknown client. + # Kick players that are using an unknown client. # Assume a legacy DRO client instruction set. - client.packet_handler = clients.ClientDRO1d0d0() - client.bad_version = True + client.disconnect() + return client.send_command_dict('FL', { 'fl_ao2_list': ['yellowtext', 'customobjections', 'flipping', 'fastloading', @@ -221,11 +211,11 @@ def net_cmd_askchaa(client: ClientManager.Client, pargs: Dict[str, Any]): client.can_askchaa = False # Enforce the joining process happening atomically # Make sure there is enough room for the client - char_cnt = len(client.server.character_manager.get_characters()) + char_cnt = len(client.hub.character_manager.get_characters()) evi_cnt = 0 music_cnt = sum([len(item['songs']) + 1 for item in client.music_manager.get_music()]) # +1 for category - area_cnt = len(client.server.area_manager.get_areas()) + area_cnt = len(client.hub.area_manager.get_areas()) client.send_command_dict('SI', { 'char_count': char_cnt, 'evidence_count': evi_cnt, @@ -256,9 +246,7 @@ def net_cmd_rc(client: ClientManager.Client, pargs: Dict[str, Any]): # Check if client is ready to actually join, and did not do weird packet shenanigans before if client.required_packets_received != {'HI', 'ID'}: return - client.send_command_dict('SC', { - 'chars_ao2_list': client.server.character_manager.get_characters(), - }) + client.send_character_list() def net_cmd_rm(client: ClientManager.Client, pargs: Dict[str, Any]): @@ -293,10 +281,7 @@ def net_cmd_rd(client: ClientManager.Client, pargs: Dict[str, Any]): client.send_done() if client.server.config['announce_areas']: - if client.server.config['rp_mode_enabled']: - client.send_limited_area_list() - else: - client.send_area_list() + client.send_limited_area_list() client.send_motd() client.can_askchaa = True # Allow rejoining if left to lobby but did not dc. @@ -489,8 +474,8 @@ def net_cmd_ms(client: ClientManager.Client, pargs: Dict[str, Any]): else: # As msg.startswith('') is True, this also accounts for having no required prefix. start, end = client.multi_ic[0].id, client.multi_ic[1].id + 1 - start_area = client.server.area_manager.get_area_by_id(start) - end_area = client.server.area_manager.get_area_by_id(end-1) + start_area = client.hub.area_manager.get_area_by_id(start) + end_area = client.hub.area_manager.get_area_by_id(end-1) area_range = range(start, end) truncated_msg = msg.replace(client.multi_ic_pre, '', 1) @@ -570,7 +555,7 @@ def net_cmd_ms(client: ClientManager.Client, pargs: Dict[str, Any]): client.publish_inbound_command('MS_final', pargs) for area_id in area_range: - target_area = client.server.area_manager.get_area_by_id(area_id) + target_area = client.hub.area_manager.get_area_by_id(area_id) for target in target_area.clients: target.send_ic(params=pargs, sender=client, gag_replaced=gag_replaced) @@ -591,14 +576,16 @@ def net_cmd_ms(client: ClientManager.Client, pargs: Dict[str, Any]): f'talking ({client.area.id}).', is_zstaff=True) # Restart AFK kick timer and lurk callout timers, if needed - client.server.tasker.create_task(client, - ['as_afk_kick', client.area.afk_delay, client.area.afk_sendto]) + client.server.task_manager.new_task(client, 'as_afk_kick', { + 'afk_delay': client.area.afk_delay, + 'afk_sendto': client.area.afk_sendto + }) client.check_lurk() client.last_ic_message = msg client.last_active = Constants.get_time() -def _process_ooc_command(cmd, client): +def _process_ooc_command(cmd: str, client: ClientManager.Client): called_function = f'ooc_cmd_{cmd}' if hasattr(client.server.commands, called_function): function = getattr(client.server.commands, called_function) @@ -673,7 +660,7 @@ def net_cmd_ct(client: ClientManager.Client, pargs: Dict[str, Any]): if ex.message: client.send_ooc(ex) else: - client.send_ooc(type(ex).__name__) + raise else: client.send_ooc(f'Invalid command `{cmd}`.') else: @@ -697,6 +684,85 @@ def net_cmd_ct(client: ClientManager.Client, pargs: Dict[str, Any]): client.last_active = Constants.get_time() +def _attempt_to_change_hub(client: ClientManager.Client, pargs: Dict[str, Any]) -> bool: + name: str = pargs['name'] + + if name == Constants.get_first_area_list_item('AREA', client.hub, client.area): + client.viewing_hubs = False + client.send_music_list_view() + + return True + else: + try: + delimiter = name.find('-') + numerical_id = int(name[:delimiter]) + hub = client.hub.manager.get_managee_by_numerical_id(numerical_id) + except (HubError.ManagerInvalidGameIDError, ValueError): + return False + + try: + client.change_hub(hub, from_party=True if client.party else False) + except (ClientError, PartyError) as ex: + client.send_ooc(ex) + + return True + + +def _attempt_to_change_area(client: ClientManager.Client, pargs: Dict[str, Any]) -> bool: + name: str = pargs['name'] + + if name == Constants.get_first_area_list_item('HUB', client.hub, client.area): + client.viewing_hubs = True + client.send_music_list_view() + + return True + else: + try: + delimiter = name.find('-') + area = client.hub.area_manager.get_area_by_name(name[delimiter+1:]) + except (AreaError, ValueError): + return False + + try: + client.change_area(area, from_party=True if client.party else False) + except (ClientError, PartyError) as ex: + client.send_ooc(ex) + + return True + + +def _attempt_to_play_music(client: ClientManager.Client, pargs: Dict[str, Any]) -> bool: + name: str = pargs['name'] + + if client.is_muted: # Checks to see if the client has been muted by a mod + client.send_ooc("You have been muted by a moderator.") + return False + if not client.is_dj: + client.send_ooc('You were blockdj\'d by a moderator.') + return False + + if int(pargs['char_id']) != client.char_id: + return False + + delay = client.change_music_cd() + if delay: + client.send_ooc(f'You changed song too many times recently. Please try again ' + f'after {Constants.time_format(delay)}.') + return False + + try: + client.area.play_track(name, client, raise_if_not_found=True, + reveal_sneaked=True, pargs=pargs) + except ServerError.FileInvalidNameError: + client.send_ooc(f'Invalid area or music `{name}`.') + return False + except MusicError.MusicNotFoundError: + client.send_ooc(f'Unrecognized area or music `{name}`.') + return False + + return True + + def net_cmd_mc(client: ClientManager.Client, pargs: Dict[str, Any]): """ Play music. @@ -704,42 +770,20 @@ def net_cmd_mc(client: ClientManager.Client, pargs: Dict[str, Any]): """ - # First attempt to switch area, - # because music lists typically include area names for quick access - try: - delimiter = pargs['name'].find('-') - area = client.server.area_manager.get_area_by_name(pargs["name"][delimiter+1:]) - client.change_area(area, from_party=True if client.party else False) - - # Otherwise, attempt to play music. - except (AreaError, ValueError): - if client.is_muted: # Checks to see if the client has been muted by a mod - client.send_ooc("You have been muted by a moderator.") - return - if not client.is_dj: - client.send_ooc('You were blockdj\'d by a moderator.') - return - - if int(pargs['char_id']) != client.char_id: - return + # First attempt to switch area/hub, + # because music lists typically include area/hub names for quick access + if client.viewing_hubs: + done = _attempt_to_change_hub(client, pargs) + else: + done = _attempt_to_change_area(client, pargs) - delay = client.change_music_cd() - if delay: - client.send_ooc(f'You changed song too many times recently. Please try again ' - f'after {Constants.time_format(delay)}.') - return + if not done: + # Otherwise, attempt to play music. + done = _attempt_to_play_music(client, pargs) - try: - client.area.play_track(pargs['name'], client, raise_if_not_found=True, - reveal_sneaked=True, pargs=pargs) - except ServerError.MusicNotFoundError: - client.send_ooc(f'Unrecognized area or music `{pargs["name"]}`.') - except ServerError: - return - except (ClientError, PartyError) as ex: - client.send_ooc(ex) - - client.last_active = Constants.get_time() + # Only update last active if command finished to completion + if done: + client.last_active = Constants.get_time() def net_cmd_rt(client: ClientManager.Client, pargs: Dict[str, Any]): diff --git a/server/network/ao_protocol.py b/server/network/ao_protocol.py index 10a3103ac..49b467cce 100644 --- a/server/network/ao_protocol.py +++ b/server/network/ao_protocol.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -33,6 +34,8 @@ from server.exceptions import AOProtocolError if typing.TYPE_CHECKING: + from asyncio.proactor_events import _ProactorSocketTransport + # Avoid circular referencing from server.tsuserver import TsuserverDR @@ -59,24 +62,10 @@ def __init__(self, server: TsuserverDR): self.client = None self.buffer = '' self.ping_timeout = None - logger.log_print = logger.log_print2 if self.server.in_test else logger.log_print - - # Determine whether /exec is active or not and warn server owner if so. - if getattr(self.server.commands, "ooc_cmd_exec")(self.client, "is_exec_active") == 1: - logger.log_print(""" - - WARNING - - THE /exec COMMAND IN commands.py IS ACTIVE. - - UNLESS YOU ABSOLUTELY MEANT IT AND KNOW WHAT YOU ARE DOING, - PLEASE STOP YOUR SERVER RIGHT NOW AND DEACTIVATE IT BY GOING TO THE - commands.py FILE AND FOLLOWING THE INSTRUCTIONS UNDER ooc_cmd_exec.\n - BAD THINGS CAN AND WILL HAPPEN OTHERWISE. - """) + self.server.check_exec_active() - def connection_made(self, transport): + def connection_made(self, transport: _ProactorSocketTransport): """ Called upon a new client connecting :param transport: the transport object @@ -122,7 +111,7 @@ def _shortened_buffer(self) -> str: return f'{short_buffer} ({len(self.buffer)} bytes)' - def _process_message(self, msg): + def _process_message(self, msg: str) -> bool: if len(msg) < 2: # This immediatelly kills any client that does not even try to follow the proper # client protocol @@ -152,7 +141,7 @@ def _process_message(self, msg): dispatched = self._net_cmd_dispatcher[cmd] pargs = self._process_arguments(cmd, args, needs_auth=dispatched.needs_auth, - fallback_protocols=[clients.ClientDROLegacy]) + fallback_protocols=[clients.ClientDRO1d0d0]) self.client.publish_inbound_command(cmd, pargs) dispatched.function(self.client, pargs) @@ -165,7 +154,7 @@ def _process_message(self, msg): self.server.send_error_report(self.client, cmd, args, ex) return True - def data_received(self, data): + def data_received(self, data: bytearray): """ Handles any data received from the network. Receives data, parses them into a command and passes it diff --git a/server/network/ms3_protocol.py b/server/network/ms3_protocol.py index 10b9b9542..000558b5f 100644 --- a/server/network/ms3_protocol.py +++ b/server/network/ms3_protocol.py @@ -1,4 +1,5 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) # Current project leader: 2018-21 Chrezm/Iuvee @@ -48,15 +49,15 @@ def __init__(self, server: TsuserverDR): Instance of the server that is meant to be advertised. """ - self._server = server + self.server = server self._period_length = 20 self._announced_success = False self._session: aiohttp.ClientSession = None - self._ms_ip: str = self._server.config['masterserver_ip'] - self._own_port: int = self._server.config['port'] - self._own_name: str = self._server.config['masterserver_name'] - self._own_description: str = self._server.config['masterserver_description'] + self._ms_ip: str = self.server.config['masterserver_ip'] + self._own_port: int = self.server.config['port'] + self._own_name: str = self.server.config['masterserver_name'] + self._own_description: str = self.server.config['masterserver_description'] def _get_server_content(self) -> typing.Dict[str, typing.Any]: """ @@ -69,7 +70,7 @@ def _get_server_content(self) -> typing.Dict[str, typing.Any]: """ port = self._own_port - players = self._server.get_player_count() + players = self.server.get_player_count() name = self._own_name description = self._own_description diff --git a/server/nonstopdebate.py b/server/nonstopdebate.py index 261ccab2a..016121ad9 100644 --- a/server/nonstopdebate.py +++ b/server/nonstopdebate.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -23,60 +24,1688 @@ from __future__ import annotations +from enum import Enum, auto + import functools +import typing +from typing import Callable, Dict, Set, Any, Tuple, Type, Union + +from server import logger +from server.exceptions import NonStopDebateError, TrialMinigameError +from server.exceptions import ClientError, TimerError +from server.trialminigame import _TrialMinigame, TRIALMINIGAMES + +if typing.TYPE_CHECKING: + # Avoid circular referencing + from server.area_manager import AreaManager + from server.client_manager import ClientManager + from server.game_manager import _Team + from server.hub_manager import _Hub + from server.hubbedgame_manager import HubbedGameManager + from server.timer_manager import Timer + from server.trial_manager import _Trial + from server.tsuserver import TsuserverDR + + +class NSDMode(Enum): + """ + Modes for a nonstop debate. + """ + + PRERECORDING = auto() + RECORDING = auto() + LOOPING = auto() + INTERMISSION = auto() + INTERMISSION_POSTBREAK = auto() + INTERMISSION_TIMERANOUT = auto() + + +class _NonStopDebateTrivialInherited(_TrialMinigame): + """ + This class should not be instantiated. + """ + + def get_id(self) -> str: + """ + Return the ID of this nonstop debate. + + Returns + ------- + str + The ID. + + """ + + return super().get_id() + + def get_numerical_id(self) -> int: + """ + Return the numerical portion of the ID of this nonstop debate. + + Returns + ------- + int + Numerical portion of the ID. + """ + + return super().get_numerical_id() + + def get_name(self) -> str: + """ + Get the name of the nonstop debate. + + Returns + ------- + str + Name. + """ + + return super().get_name() + + def set_name(self, name: str): + """ + Set the name of the nonstop debate. + + Parameters + ---------- + name : str + Name. + """ + + self.unchecked_set_name(name) + self.manager._check_structure() + + def unchecked_set_name(self, name: str): + """ + Set the name of the nonstop debate. + + This method does not assert structural integrity. + + Parameters + ---------- + name : str + Name. + """ + + super().unchecked_set_name(name) + + def get_player_limit(self) -> Union[int, None]: + """ + Return the player membership limit of this nonstop debate. + + Returns + ------- + Union[int, None] + The player membership limit. + + """ + + return super().get_player_limit() + + def get_player_concurrent_limit(self) -> Union[int, None]: + """ + Return the concurrent player membership limit of this nonstop debate. + + Returns + ------- + Union[int, None] + The concurrent player membership limit. + + """ + + return super().get_player_concurrent_limit() + + def get_players( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of players of this nonstop debate that satisfy a + condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all players returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) players of this nonstop debate. + + """ + + return super().get_players(cond=cond) + + def is_player(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is a player of the nonstop debate. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Returns + ------- + bool + True if the user is a player, False otherwise. + + """ + + return super().is_player(user) + + def add_player(self, user: ClientManager.Client): + """ + Make a user a player of the nonstop debate. By default this player will not be a leader, + unless the nonstop debate has no leaders and it requires a leader. + It will also subscribe the nonstop debate to the player so it can listen to its updates. + + Newly added players will be ordered to switch to a 'nonstop debate' variant. + + Parameters + ---------- + user : ClientManager.Client + User to add to the nonstop debate. They must be in an area part of the nonstop debate. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.UserNotPlayerError + If the user is not a player of the trial. + NonStopDebateError.UserNotInAreaError + If the user is not in an area part of the nonstop debate. + NonStopDebateError.UserHasNoCharacterError + If the user has no character but the nonstop debate requires that all players have + characters. + NonStopDebateError.UserNotInvitedError + If the nonstop debate requires players be invited to be added and the user is not + invited. + NonStopDebateError.UserAlreadyPlayerError + If the user to add is already a user of the nonstop debate. + NonStopDebateError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the nonstop debate + managed by the manager of this nonstop debate, or by virtue of joining this + nonstop debate they would violate this nonstop debate's concurrent player membership + limit. + NonStopDebateError.GameIsFullError + If the nonstop debate reached its player limit. + + """ + + self.unchecked_add_player(user) + self.manager._check_structure() + + def remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this nonstop debate. If they were part of a team + managed by this nonstop debate, they will also be removed from said team. It will also + unsubscribe the nonstop debate from the player so it will no longer listen to its updates. + + If the nonstop debate required that there it always had players and by calling this method + the nonstop debate had no more players, the nonstop debate will automatically be scheduled + for deletion. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.UserNotPlayerError + If the user to remove is already not a player of this nonstop debate. + + """ + + self.unchecked_remove_player(user) + self.manager._check_structure() + + def requires_players(self) -> bool: + """ + Return whether the nonstop debate requires players at all times. + + Returns + ------- + bool + Whether the nonstop debate requires players at all times. + """ + + return super().requires_players() + + def get_invitations( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of invited users of this nonstop debate that satisfy a + condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all invited users returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) invited users of this nonstop debate. + + """ + + return super().get_invitations(cond=cond) + + def is_invited(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is invited to the nonstop debate. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Raises + ------ + NonStopDebateError.UserAlreadyPlayerError + If the user is a player of this nonstop debate. + + Returns + ------- + bool + True if the user is invited, False otherwise. + + """ + + try: + return super().is_invited(user) + except TrialMinigameError.UserAlreadyPlayerError: + raise NonStopDebateError.UserAlreadyPlayerError + + def add_invitation(self, user: ClientManager.Client): + """ + Mark a user as invited to this nonstop debate. + + Parameters + ---------- + user : ClientManager.Client + User to invite to the nonstop debate. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameDoesNotTakeInvitationsError + If the nonstop debate does not require users be invited to the nonstop debate. + NonStopDebateError.UserAlreadyInvitedError + If the player to invite is already invited to the nonstop debate. + NonStopDebateError.UserAlreadyPlayerError + If the player to invite is already a player of the nonstop debate. + + """ + + self.unchecked_add_invitation(user) + self.manager._check_structure() + + def unchecked_add_invitation(self, user: ClientManager.Client): + """ + Mark a user as invited to this nonstop debate. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to invite to the nonstop debate. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameDoesNotTakeInvitationsError + If the nonstop debate does not require users be invited to the nonstop debate. + NonStopDebateError.UserAlreadyInvitedError + If the player to invite is already invited to the nonstop debate. + NonStopDebateError.UserAlreadyPlayerError + If the player to invite is already a player of the nonstop debate. + + """ + + try: + super().unchecked_add_invitation(user) + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.GameDoesNotTakeInvitationsError: + raise NonStopDebateError.GameDoesNotTakeInvitationsError + except TrialMinigameError.UserAlreadyInvitedError: + raise NonStopDebateError.UserAlreadyInvitedError + except TrialMinigameError.UserAlreadyPlayerError: + raise NonStopDebateError.UserAlreadyPlayerError + + def remove_invitation(self, user: ClientManager.Client): + """ + Mark a user as no longer invited to this nonstop debate (uninvite). + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameDoesNotTakeInvitationsError + If the nonstop debate does not require users be invited to the nonstop debate. + NonStopDebateError.UserNotInvitedError + If the user to uninvite is already not invited to this nonstop debate. + + """ + + self.unchecked_remove_invitation(user) + self.manager._check_structure() + + def unchecked_remove_invitation(self, user: ClientManager.Client): + """ + Mark a user as no longer invited to this nonstop debate (uninvite). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameDoesNotTakeInvitationsError + If the nonstop debate does not require users be invited to the nonstop debate. + NonStopDebateError.UserNotInvitedError + If the user to uninvite is already not invited to this nonstop debate. + + """ + + try: + super().unchecked_remove_invitation(user) + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.GameDoesNotTakeInvitationsError: + raise NonStopDebateError.GameDoesNotTakeInvitationsError + except TrialMinigameError.UserNotInvitedError: + raise NonStopDebateError.UserNotInvitedError + + def requires_invitations(self): + """ + Return True if the nonstop debate requires players be invited before being allowed to join + the nonstop debate, False otherwise. + + Returns + ------- + bool + True if the nonstop debate requires players be invited before being allowed to join + the nonstop debate, False otherwise. + """ + + return super().requires_invitations() + + def get_leaders( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of leaders of this nonstop debate that satisfy a condition + if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all leaders returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) leaders of this nonstop debate. + + """ + + return super().get_leaders(cond=cond) + + def get_regulars( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of players of this nonstop debate that are regulars and + satisfy a condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all regulars returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) regulars of this nonstop debate. + + """ + + return super().get_regulars(cond=cond) + + def is_leader(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is a leader of the nonstop debate. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Raises + ------ + NonStopDebateError.UserNotPlayerError + If the player to test is not a player of this nonstop debate. + + Returns + ------- + bool + True if the player is a user, False otherwise. + + """ + + try: + return super().is_leader(user) + except TrialMinigameError.UserNotPlayerError: + raise NonStopDebateError.UserNotPlayerError + + def add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this nonstop debate (promote to leader). + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.UserNotPlayerError + If the player to promote is not a player of this nonstop debate. + NonStopDebateError.UserAlreadyLeaderError + If the player to promote is already a leader of this nonstop debate. + + """ + + self.unchecked_add_leader(user) + self.manager._check_structure() + + def unchecked_add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this nonstop debate (promote to leader). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.UserNotPlayerError + If the player to promote is not a player of this nonstop debate. + NonStopDebateError.UserAlreadyLeaderError + If the player to promote is already a leader of this nonstop debate. + + """ + + try: + super().unchecked_add_leader(user) + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.UserNotPlayerError: + raise NonStopDebateError.UserNotPlayerError + except TrialMinigameError.UserAlreadyLeaderError: + raise NonStopDebateError.UserAlreadyLeaderError + + def remove_leader(self, user: ClientManager.Client): + """ + Make a user no longer leader of this nonstop debate (demote). + + Parameters + ---------- + user : ClientManager.Client + User to demote. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.UserNotPlayerError + If the player to demote is not a player of this nonstop debate. + NonStopDebateError.UserNotLeaderError + If the player to demote is already not a leader of this nonstop debate. + + """ + + self.unchecked_remove_leader(user) + self.manager._check_structure() + + def unchecked_remove_leader(self, user: ClientManager.Client): + """ + Make a user no longer leader of this nonstop debate (demote). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to demote. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.UserNotPlayerError + If the player to demote is not a player of this nonstop debate. + NonStopDebateError.UserNotLeaderError + If the player to demote is already not a leader of this nonstop debate. + + """ + + try: + super().unchecked_remove_leader(user) + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.UserNotPlayerError: + raise NonStopDebateError.UserNotPlayerError + except TrialMinigameError.UserNotLeaderError: + raise NonStopDebateError.UserNotLeaderError + + def has_ever_had_players(self) -> bool: + """ + Return True if a player has ever been added to this nonstop debate, False otherwise. + + Returns + ------- + bool + True if the nonstop debate has ever had a player added, False otherwise. + + """ + + return super().has_ever_had_players() + + def requires_leaders(self) -> bool: + """ + Return whether the nonstop debate requires leaders at all times. + + Returns + ------- + bool + Whether the nonstop debate requires leaders at all times. + """ + + return super().requires_leaders() + + def requires_participant_characters(self) -> bool: + """ + Return whether the nonstop debate requires players have a participant character at all times. + + Returns + ------- + bool + Whether the nonstop debate requires players have a participant character at all times. + """ + + return super().requires_participant_characters() + + def new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this nonstop debate with given parameters. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the nonstop debate will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameTooManyTimersError + If the nonstop debate is already managing its maximum number of timers. + + """ + + timer = self.unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + self.manager._check_structure() + return timer + + def unchecked_new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this nonstop debate with given parameters. + + This method does not assert structural integrity. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the nonstop debate will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameTooManyTimersError + If the nonstop debate is already managing its maximum number of timers. + + """ + + try: + return super().unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.GameTooManyTimersError: + raise NonStopDebateError.GameTooManyTimersError + + def delete_timer(self, timer: Timer) -> str: + """ + Delete a timer managed by this nonstop debate, terminating it first if needed. + + Parameters + ---------- + timer : Timer + The timer to delete. + + Returns + ------- + str + The ID of the timer that was deleted. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameDoesNotManageTimerError + If the nonstop debate does not manage the target timer. + + """ + + timer_id = self.unchecked_delete_timer(timer) + self.manager._check_structure() + return timer_id + + def unchecked_delete_timer(self, timer: Timer) -> str: + """ + Delete a timer managed by this nonstop debate, terminating it first if needed. + + This method does not assert structural integrity. + + Parameters + ---------- + timer : Timer + The timer to delete. + + Returns + ------- + str + The ID of the timer that was deleted. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameDoesNotManageTimerError + If the nonstop debate does not manage the target timer. + + """ + + try: + return super().unchecked_delete_timer(timer) + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.GameDoesNotManageTimerError: + raise NonStopDebateError.GameDoesNotManageTimerError + + def get_timers(self) -> Set[Timer]: + """ + Return (a shallow copy of) the timers this nonstop debate manages. + + Returns + ------- + Set[Timer] + Timers this nonstop debate manages. + + """ + + return super().get_timers() + + def get_timer_by_id(self, timer_id: str) -> Timer: + """ + If `timer_tag` is the ID of a timer managed by this nonstop debate, return that timer. + + Parameters + ---------- + timer_id: str + ID of timer this nonstop debate manages. + + Returns + ------- + Timer + The timer whose ID matches the given ID. + + Raises + ------ + NonStopDebateError.GameInvalidTimerIDError: + If `timer_tag` is a str and it is not the ID of a timer this nonstop debate manages. + + """ + + try: + return super().get_timer_by_id(timer_id) + except TrialMinigameError.GameInvalidTimerIDError: + raise NonStopDebateError.GameInvalidTimerIDError + + def get_timer_limit(self) -> Union[int, None]: + """ + Return the timer limit of this nonstop debate. + + Returns + ------- + Union[int, None] + Timer limit. + + """ + + return super().get_timer_limit() + + def get_timer_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all timers managed by this nonstop debate. + + Returns + ------- + Set[str] + The IDs of all managed timers. + + """ + + return super().get_timer_ids() + + def new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this nonstop debate. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this nonstop debate. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + + Returns + ------- + _Team + The created team. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameTooManyTeamsError + If the nonstop debate is already managing its maximum number of teams. + NonStopDebateError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this nonstop debate. + + """ + + team = self.unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + self.manager._check_structure() + return team + + def unchecked_new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this nonstop debate. + + This method does not assert structural integrity. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this nonstop debate. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + + Returns + ------- + _Team + The created team. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameTooManyTeamsError + If the nonstop debate is already managing its maximum number of teams. + NonStopDebateError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this nonstop debate. + + """ + + try: + return super().unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.GameTooManyTeamsError: + raise NonStopDebateError.GameTooManyTeamsError + except TrialMinigameError.UserInAnotherTeamError: + raise NonStopDebateError.UserInAnotherTeamError + + def delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this nonstop debate. + + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameDoesNotManageTeamError + If the nonstop debate does not manage the target team. + + """ + + team_id, players = self.unchecked_delete_team(team) + self.manager._check_structure() + return team_id, players + + def unchecked_delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this nonstop debate. + + This method does not assert structural integrity. + + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.GameDoesNotManageTeamError + If the nonstop debate does not manage the target team. + + """ + + try: + return super().unchecked_delete_team(team) + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.GameDoesNotManageTeamError: + raise NonStopDebateError.GameDoesNotManageTeamError + + def manages_team(self, team: _Team) -> bool: + """ + Return True if the team is managed by this nonstop debate, False otherwise. + + Parameters + ---------- + team : _Team + The team to check. + + Returns + ------- + bool + True if the nonstop debate manages this team, False otherwise. + + """ + + return super().manages_team(team) + + def get_teams(self) -> Set[_Team]: + """ + Return (a shallow copy of) the teams this nonstop debate manages. + + Returns + ------- + Set[_Team] + Teams this nonstop debate manages. + + """ + + return super().get_teams() + + def get_team_by_id(self, team_id: str) -> _Team: + """ + If `team_id` is the ID of a team managed by this nonstop debate, return the team. + + Parameters + ---------- + team_id : str + ID of the team this nonstop debate manages. + + Returns + ------- + _Team + The team that matches the given ID. + + Raises + ------ + NonStopDebateError.GameInvalidTeamIDError: + If `team_id` is not the ID of a team this nonstop debate manages. + + """ + + try: + return super().get_team_by_id(team_id) + except TrialMinigameError.GameInvalidTeamIDError: + raise NonStopDebateError.GameInvalidTeamIDError + + def get_team_limit(self) -> Union[int, None]: + """ + Return the team limit of this nonstop debate. + + Returns + ------- + Union[int, None] + Team limit. + + """ + + return super().get_team_limit() + + def get_team_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all teams managed by this nonstop debate. + + Returns + ------- + Set[str] + The IDs of all managed teams. + + """ + + return super().get_team_ids() + + def get_teams_of_user(self, user: ClientManager.Client) -> Set[_Team]: + """ + Return (a shallow copy of) the teams managed by this nonstop debate user `user` is a player + of. If the user is part of no such team, an empty set is returned. + + Parameters + ---------- + user : ClientManager.Client + User whose teams will be returned. + + Returns + ------- + Set[_Team] + Teams the player belongs to. + + """ + + return super().get_teams_of_user(user) + + def get_users_in_some_team(self): + """ + Return (a shallow copy of) all the users that are part of some team managed by this + nonstop debate. + + Returns + ------- + Set[ClientManager.Client] + Users in some managed team. + + """ + + return super().get_users_in_some_team() + + def get_available_team_id(self) -> str: + """ + Get a team ID that no other team managed by this team has. + + Returns + ------- + str + A unique team ID. + + Raises + ------ + NonStopDebateError.GameTooManyTeamsError + If the nonstop debate is already managing its maximum number of teams. + + """ + + try: + return super().get_available_team_id() + except TrialMinigameError.GameTooManyTeamsError: + raise NonStopDebateError.GameTooManyTeamsError + + def get_autoadd_on_client_enter(self) -> bool: + """ + Return True if the nonstop debate will always attempt to add nonplayer users who enter an + area part of the nonstop debate, False otherwise. + + Returns + ------- + bool + True if the nonstop debate will always attempt to add nonplayer users who enter an area + part of the nonstop debate, False otherwise. + """ + + return super().get_autoadd_on_client_enter() + + def set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + Parameters + ---------- + new_value : bool + New value. + + """ + + self.unchecked_set_autoadd_on_client_enter(new_value) + self.manager._check_structure() + + def unchecked_set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + This method does not assert structural integrity. + + Parameters + ---------- + new_value : bool + New value. + + """ + + super().unchecked_set_autoadd_on_client_enter(new_value) + + def add_area(self, area: AreaManager.Area): + """ + Add an area to this nonstop debate's set of areas. + + Parameters + ---------- + area : AreaManager.Area + Area to add. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.AreaDisallowsBulletsError + If the area to add disallows bullets. + NonStopDebateError.AreaAlreadyInGameError + If the area is already part of the nonstop debate. + NonStopDebateError.AreaHitGameConcurrentLimitError. + If `area` has reached the concurrent area membership limit of any of the games with + areas it belongs to managed by this manager, or by virtue of adding this area it will + violate this nonstop debate's concurrent area membership limit. + + """ + + self.unchecked_add_area(area) + self.manager._check_structure() + + def unchecked_add_area(self, area): + """ + Add an area to this nonstop debate's set of areas. + + This method does not assert structural integrity. + + Parameters + ---------- + area : AreaManager.Area + Area to add. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.AreaNotInGameError + If the area is not part of the trial of the nonstop debate. + NonStopDebateError.AreaAlreadyInGameError + If the area is already part of the nonstop debate. + NonStopDebateError.AreaHitGameConcurrentLimitError. + If `area` has reached the concurrent area membership limit of any of the games it + belongs to managed by this manager, or by virtue of adding this area it will violate + this nonstop debate's concurrent area membership limit. + + """ + + try: + super().unchecked_add_area(area) + except TrialMinigameError.AreaNotInGameError: + raise NonStopDebateError.AreaNotInGameError + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.AreaAlreadyInGameError: + raise NonStopDebateError.AreaAlreadyInGameError + except TrialMinigameError.AreaHitGameConcurrentLimitError: + raise NonStopDebateError.AreaHitGameConcurrentLimitError + + def remove_area(self, area: AreaManager.Area): + """ + Remove an area from this nonstop debate's set of areas. + If the area is already a part of the nonstop debate, do nothing. + If any player of the nonstop debate is in this area, they are removed from the + nonstop debate. + If the nonstop debate has no areas remaining, it will be automatically destroyed. + + Parameters + ---------- + area : AreaManager.Area + Area to remove. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.AreaNotInGameError + If the area is already not part of the nonstop debate. -from enum import Enum, auto -import typing -from typing import Any, Dict, Set + """ -from server import logger -from server.exceptions import ClientError, NonStopDebateError, GameError, TimerError -from server.trialminigame import TrialMinigame, TRIALMINIGAMES + self.unchecked_remove_area(area) + self.manager._check_structure() -if typing.TYPE_CHECKING: - # Avoid circular referencing - from server.area_manager import AreaManager - from server.client_manager import ClientManager + def unchecked_remove_area(self, area: AreaManager.Area): + """ + Remove an area from this nonstop debate's set of areas. + If the area is already a part of the nonstop debate, do nothing. + If any player of the nonstop debate is in this area, they are removed from the + nonstop debate. + If the nonstop debate has no areas remaining, it will be automatically destroyed. + This method does not assert structural integrity. -class NSDMode(Enum): - """ - Modes for a nonstop debate. - """ + Parameters + ---------- + area : AreaManager.Area + Area to remove. - PRERECORDING = auto() - RECORDING = auto() - LOOPING = auto() - INTERMISSION = auto() - INTERMISSION_POSTBREAK = auto() - INTERMISSION_TIMERANOUT = auto() + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.AreaNotInGameError + If the area is already not part of the nonstop debate. + + """ + + try: + super().unchecked_remove_area(area) + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.AreaNotInGameError: + raise NonStopDebateError.AreaNotInGameError + + def requires_areas(self) -> bool: + """ + Return whether the nonstop debate requires areas at all times. + + Returns + ------- + bool + Whether the nonstop debate requires areas at all times. + """ + + return super().requires_areas() + + def has_area(self, area: AreaManager.Area) -> bool: + """ + If the area is part of this nonstop debate's set of areas, return True; otherwise, return + False. + + Parameters + ---------- + area : AreaManager.Area + Area to check. + + Returns + ------- + bool + True if the area is part of the nonstop debate's set of areas, False otherwise. + + """ + + return super().has_area(area) + + def get_areas(self) -> Set[AreaManager.Area]: + """ + Return (a shallow copy of) the set of areas of this nonstop debate. + + Returns + ------- + Set[AreaManager.Area] + Set of areas of the nonstop debate. + + """ + + return super().get_areas() + + def get_area_concurrent_limit(self) -> Union[int, None]: + """ + Return the concurrent area membership limit of this nonstop debate. + + Returns + ------- + Union[int, None] + The concurrent area membership limit. + + """ + + return super().get_area_concurrent_limit() + + def get_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the nonstop debate, even those that are not players of + the nonstop debate. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the nonstop debate. + + """ + + return super().get_users_in_areas() + + def get_nonleader_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the nonstop debate, even those that are not players of + the nonstop debate, such that they are not leaders of the nonstop debate. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the nonstop debate that are not leaders of the nonstop debate. + + """ + + return super().get_nonleader_users_in_areas() + + def get_nonplayer_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the nonstop debate that are not players of the + nonstop debate. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the nonstop debate that are not players of the nonstop debate. + + """ + + return super().get_nonplayer_users_in_areas() + + def get_trial(self) -> _Trial: + """ + Return the trial of the nonstop debate. + + Returns + ------- + _Trial + Trial of the nonstop debate. + + """ + + return super().get_trial() + + def get_autoadd_on_trial_player_add(self) -> bool: + """ + Return whether the nonstop debate will attempt to add players to it if the parent trial + added it as player. + + Returns + ------- + bool. + True if an attempt will be made automatically, False otherwise. + """ + + return super().get_autoadd_on_trial_player_add() + + def set_autoadd_on_trial_player_add(self, new_value: bool): + """ + Set the new value of the autoadd on trial adding a player flag. + + Parameters + ---------- + new_value : bool + New value. + + """ + + self.unchecked_set_autoadd_on_trial_player_add(new_value) + self.manager._check_structure() + + def unchecked_set_autoadd_on_trial_player_add(self, new_value: bool): + """ + Set the new value of the autoadd on trial adding a player flag. + + Parameters + ---------- + new_value : bool + New value. + + """ + + super().unchecked_set_autoadd_on_trial_player_add(new_value) + + def is_unmanaged(self): + """ + Return True if this nonstop debate is unmanaged, False otherwise. + + Returns + ------- + bool + True if unmanaged, False otherwise. + """ + + return super().is_unmanaged() + + def destroy(self): + """ + Mark this nonstop debate as destroyed and notify its manager so that it is deleted. + If the nonstop debate is already destroyed, this function does nothing. + A nonstop debate marked for destruction will delete all of its timers, teams, remove all + its players and unsubscribe it from updates of its former players. + + This method is reentrant (it will do nothing though). + + Returns + ------- + None. + + """ + + self.unchecked_destroy() + self.manager._check_structure() + self._check_structure() # Manager will not check this otherwise. + + def _on_area_destroyed(self, area: AreaManager.Area): + """ + Default callback for nonstop debate area signaling it was destroyed. + + By default it calls self.unchecked_remove_area(area). + + Parameters + ---------- + area : AreaManager.Area + Area that signaled it was destroyed. + + Returns + ------- + None. + + """ + + super()._on_area_destroyed(area) + + def _on_trial_player_added( + self, + trial: _Trial, + player: ClientManager.Client = None + ): + + """ + Default callback when the parent trial adds a player. + If a player was added to the trial of the nonstop debate, attempt to add the player to the + nonstop debate as well. If unsuccessful, do nothing. + Do note the player may already be part of the nonstop debate by this point: if another + thread was also listening to this callback and acted upon it before the current thread by + adding the player to the nonstop debate. + + Parameters + ---------- + trial : TrialManager._Trial + Trial that generated the callback. Typically is self.get_trial(). + player : ClientManager.Client, optional + Player that was added to the trial. + + Returns + ------- + None. + + """ -class NonStopDebate(TrialMinigame): + super()._on_trial_player_added(trial, player=player) + + +class _NonStopDebate(_NonStopDebateTrivialInherited): """ A nonstop debate is a trial game based in its Danganronpa counterpart. Attributes ---------- + server : TsuserverDR + Server the nonstop debate belongs to. + manager : HubbedGameManager + Manager for this nonstop debate. + hub: _Hub + Hub for this hubbed game. listener : Listener - Standard listener of the game. + Standard listener of the nonstop debate. Callback Methods ---------------- - _on_area_client_left_final - Method to perform once a client left an area of the game. - _on_area_client_entered_final - Method to perform once a client entered an area of the game. - _on_area_destroyed - Method to perform once an area of the game is marked for destruction. _on_client_inbound_ms_check - Method to perform once a player of the game wants to send an IC message. + Method to perform once a player of the nonstop debate wants to send an IC message. _on_client_inbound_ms_final - Method to perform once a player of the game sends an IC message. + Method to perform once a player of the nonstop debate sends an IC message. _on_client_change_character - Method to perform once a player of the game has changed character. + Method to perform once a player of the nonstop debate has changed character. _on_client_destroyed - Method to perform once a player of the game is destroyed. + Method to perform once a player of the nonstop debate is destroyed. + _on_area_client_left_final + Method to perform once a client left an area of the nonstop debate. + _on_area_client_entered_final + Method to perform once a client entered an area of the nonstop debate. + _on_area_destroyed + Method to perform once an area of the nonstop debate is marked for destruction. """ @@ -91,12 +1720,28 @@ class NonStopDebate(TrialMinigame): # ---------- # 1. The invariants from the parent class TrialMinigame are satisfied. - def __init__(self, server, manager, NSD_id, player_limit=None, - player_concurrent_limit=None, require_invitations=False, require_players=True, - require_leaders=True, require_character=False, team_limit=None, - timer_limit=None, area_concurrent_limit=1, autoadd_on_client_enter=False, - trial=None, autoadd_on_trial_player_add=False, - timer_start_value=300, playergroup_manager=None): + def __init__( + self, + server: TsuserverDR, + manager: HubbedGameManager, + nsd_id: str, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + area_concurrent_limit: Union[int, None] = None, + autoadd_on_client_enter: bool = False, + require_areas: bool = True, + hub: _Hub = None, + trial: _Trial = None, + autoadd_on_trial_player_add: bool = False, + # new + timer_start_value: int = 300, + ): """ Create a new nonstop debate (NSD) game. An NSD should not be fully initialized anywhere else other than some manager code, as otherwise the manager will not recognize the NSD. @@ -105,9 +1750,9 @@ def __init__(self, server, manager, NSD_id, player_limit=None, ---------- server : TsuserverDR Server the NSD belongs to. - manager : GameManager + manager : HubbedGameManager Manager for this NSD. - NSD_id : str + nsd_id : str Identifier of the NSD. player_limit : int or None, optional If an int, it is the maximum number of players the NSD supports. If None, it @@ -129,11 +1774,12 @@ def __init__(self, server, manager, NSD_id, player_limit=None, leader among any remaining players left; if no players are left, the next player added will be made leader. If False, no such automatic assignment will happen. Defaults to True. - require_character : bool, optional - If False, players without a character will not be allowed to join the NSD, and players - that switch to something other than a character will be automatically removed from the - NSD. If False, no such checks are made. A player without a character is considered - one where player.has_character() returns False. Defaults to False. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + NSD, and players that switch to something other than a participant character + will be automatically removed from the NSD. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. team_limit : int or None, optional If an int, it is the maximum number of teams the NSD supports. If None, it indicates the NSD has no team limit. Defaults to None. @@ -145,31 +1791,28 @@ def __init__(self, server, manager, NSD_id, player_limit=None, area_concurrent_limit : int or None, optional If an int, it is the maximum number of trials managed by `manager` that any area of this trial may belong to, including this trial. If None, it indicates - that this game does not care about how many other trials managed by + that this nonstop debate does not care about how many other trials managed by `manager` each of its areas belongs to. Defaults to 1 (an area may not be a part of another trial managed by `manager` while being an area of this trial). autoadd_on_client_enter : bool, optional - If True, nonplayer users that enter an area part of the game will be automatically - added if permitted by the conditions of the game. If False, no such adding will take - place. Defaults to False. - trial : TrialManager.Trial, optional + If True, nonplayer users that enter an area part of the nonstop debate will be + automatically added if permitted by the conditions of the nonstop debate. If False, no + such adding will take place. Defaults to False. + require_areas : bool, optional + If True, if at any point the nonstop debate has no areas left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + hub : _Hub, optional + Hub the nonstop debate belongs to. Defaults to None. + trial : _Trial, optional Trial the nonstop debate is a part of. Defaults to None. + autoadd_on_trial_player_add : bool, optional + If True, players that are added to the trial will be automatically added if permitted + by the conditions of the nonstop debate. If False, no such adding will take place. + Defaults to False. timer_start_value : float, optional In seconds, the length of time the main timer of this nonstop debate will have at the start. It must be a positive number. Defaults to 300 (5 minutes). - autoadd_on_trial_player_add : bool, optional - If True, players that are added to the trial will be automatically added if permitted - by the conditions of the game. If False, no such adding will take place. Defaults to - False. - playergroup_manager : PlayerGroupManager, optional - The internal playergroup manager of the game manager. Access to this value is - limited exclusively to this __init__, and is only to initialize the internal - player group of the NSD. - - Raises - ------ - GameError.ManagerTooManyGamesError - If the manager is already managing its maximum number of NSDs. """ @@ -178,16 +1821,6 @@ def __init__(self, server, manager, NSD_id, player_limit=None, self._messages = list() self._message_index = -1 - super().__init__(server, manager, NSD_id, player_limit=player_limit, - player_concurrent_limit=player_concurrent_limit, - require_invitations=require_invitations, - require_players=require_players, require_leaders=require_leaders, - require_character=require_character, team_limit=team_limit, - timer_limit=timer_limit, area_concurrent_limit=area_concurrent_limit, - autoadd_on_client_enter=autoadd_on_client_enter, - trial=trial, autoadd_on_trial_player_add=autoadd_on_trial_player_add, - playergroup_manager=playergroup_manager) - self._timer = None self._message_timer = None self._player_refresh_timer = None @@ -202,19 +1835,138 @@ def __init__(self, server, manager, NSD_id, player_limit=None, self._mode_switch_lockout_lock = True self._intermission_messages = 0 - def get_name(self) -> str: + super().__init__( + server, + manager, + nsd_id, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + require_areas=require_areas, + hub=hub, + trial=trial, + autoadd_on_trial_player_add=autoadd_on_trial_player_add, + ) + + def get_type_name(self) -> str: """ - Return "nonstop debate" + Return the type name of the nonstop debate. Names are fully lowercase. + Implementations of the class should replace this with a human readable name of the nonstop + debate. Returns ------- str - "nonstop debate". + Type name of the nonstop debate. """ return "nonstop debate" + def unchecked_add_player(self, user: ClientManager.Client): + """ + Make a user a player of the nonstop debate. By default this player will not be a leader, + unless the nonstop debate has no leaders and it requires a leader. + It will also subscribe the nonstop debate ot the player so it can listen to its updates. + + It will also send a gamemode change order to the new player that aligns with the current + mode of the NSD. + + Parameters + ---------- + user : ClientManager.Client + User to add to the nonstop debate. They must be in an area part of the nonstop debate. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.UserNotPlayerError + If the user is not a player of the trial. + NonStopDebateError.UserNotInAreaError + If the user is not in an area part of the nonstop debate. + NonStopDebateError.UserHasNoCharacterError + If the user has no character but the nonstop debate requires that all players have + characters. + NonStopDebateError.UserNotInvitedError + If the nonstop debate requires players be invited to be added and the user is not + invited. + NonStopDebateError.UserAlreadyPlayerError + If the user to add is already a user of the nonstop debate. + NonStopDebateError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the nonstop debates + managed by the manager of this nonstop debate, or by virtue of joining this + nonstop debate they would violate this nonstop debate's concurrent player membership + limit. + NonStopDebateError.GameIsFullError + If the nonstop debate reached its player limit. + + """ + + try: + super().unchecked_add_player(user) + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.UserNotPlayerError: + raise NonStopDebateError.UserNotPlayerError + except TrialMinigameError.UserNotInAreaError: + raise NonStopDebateError.UserNotInAreaError + except TrialMinigameError.UserHasNoCharacterError: + raise NonStopDebateError.UserHasNoCharacterError + except TrialMinigameError.UserNotInvitedError: + raise NonStopDebateError.UserNotInvitedError + except TrialMinigameError.UserAlreadyPlayerError: + raise NonStopDebateError.UserAlreadyPlayerError + except TrialMinigameError.UserHitGameConcurrentLimitError: + raise NonStopDebateError.UserHitGameConcurrentLimitError + except TrialMinigameError.GameIsFullError: + raise NonStopDebateError.GameIsFullError + + self.introduce_user(user) + + def unchecked_remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this nonstop debate. If they were part of a team + managed by this nonstop debate, they will also be removed from said team. It will also + unsubscribe the nonstop debate from the player so it will no longer listen to its updates. + It will also send an order to the player to go back to its default theme gamemode. + + If the nonstop debate required that there it always had players and by calling this method + the nonstop debate had no more players, the nonstop debate will automatically be scheduled + for deletion. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + NonStopDebateError.GameIsUnmanagedError + If the nonstop debate was scheduled for deletion and thus does not accept any mutator + public method calls. + NonStopDebateError.UserNotPlayerError + If the user to remove is already not a player of this nonstop debate. + + """ + + try: + super().unchecked_remove_player(user) + except TrialMinigameError.GameIsUnmanagedError: + raise NonStopDebateError.GameIsUnmanagedError + except TrialMinigameError.UserNotPlayerError: + raise NonStopDebateError.UserNotPlayerError + + self.dismiss_user(user) + def introduce_user(self, user: ClientManager.Client): """ Broadcast information relevant for a user entering an area of the NSD, namely current @@ -226,10 +1978,6 @@ def introduce_user(self, user: ClientManager.Client): user : ClientManager.Client User to introduce. - Returns - ------- - None. - """ if self._mode in [NSDMode.LOOPING, NSDMode.RECORDING, NSDMode.PRERECORDING]: @@ -248,7 +1996,7 @@ def introduce_user(self, user: ClientManager.Client): user.send_timer_resume(timer_id=self._client_timer_id) else: user.send_timer_pause(timer_id=self._client_timer_id) - self._update_player_timer(user) + self._send_current_timers(user) def dismiss_user(self, user: ClientManager.Client): """ @@ -266,10 +2014,6 @@ def dismiss_user(self, user: ClientManager.Client): user : ClientManager.Client User to dismiss. - Returns - ------- - None. - """ if not self.has_ever_had_players(): @@ -279,15 +2023,15 @@ def dismiss_user(self, user: ClientManager.Client): # of the user moving, in which case .area still points to the user's old area. # If the user is still part of an area of the NSD, do nothing - if user.new_area in self.get_areas(): - pass - # Otherwise, if the user is still part of an area part of the NSD's trial, make them switch - # to the trial gamemode - elif user.new_area in self._trial.get_areas(): - user.send_gamemode(name='trial') - # Otherwise, fully clear out gamemode - else: - user.send_gamemode(name='') + # Otherwise, ... + if user.new_area not in self.get_areas(): + # If the user is still part of an area part of the NSD's trial, make them + # switch to the trial gamemode + if user.new_area in self._trial.get_areas(): + user.send_gamemode(name='trial') + # Otherwise, fully clear out gamemode + else: + user.send_gamemode(name='') # Update the timers only if the player is not in an area part of the NSD if user.new_area not in self.get_areas(): @@ -334,10 +2078,6 @@ def set_prerecording(self): NonStopDebateError.NSDAlreadyInModeError If the nonstop debate is already in recording mode. - Returns - ------- - None. - """ if self._mode == NSDMode.PRERECORDING: @@ -358,22 +2098,12 @@ def set_prerecording(self): pass for user in self.get_users_in_areas(): - user.send_gamemode(name='nsd') - self._update_player_timer(user) - - def _set_recording(self): - """ - Set the NSD to be in recording mode. - - Raises - ------ - NonStopDebateError.NSDAlreadyInModeError - If the nonstop debate is already in recording mode. - - Returns - ------- - None. + user.send_gamemode(name='nsd') + self._send_current_timers(user) + def _set_recording(self): + """ + Set the NSD to be in recording mode. """ if self._mode != NSDMode.PRERECORDING: @@ -388,7 +2118,7 @@ def _set_recording(self): for user in self.get_users_in_areas(): user.send_gamemode(name='nsd') user.send_timer_resume(timer_id=self._client_timer_id) - self._update_player_timer(user) + self._send_current_timers(user) for leader in self.get_leaders(): leader.send_ooc('(X) Messages for your nonstop debate are now being recorded. Once you ' @@ -414,10 +2144,6 @@ def set_intermission(self, blankpost: bool = True): NonStopDebateError.NSDNotInModeError If the nonstop debate is in prerecording mode. - Returns - ------- - None. - """ if self._mode in [NSDMode.INTERMISSION, NSDMode.INTERMISSION_POSTBREAK, @@ -445,7 +2171,7 @@ def set_intermission(self, blankpost: bool = True): for user in self.get_users_in_areas(): user.send_timer_pause(timer_id=self._client_timer_id) - self._update_player_timer(user) + self._send_current_timers(user) if blankpost: user.send_ic_blankpost() # Blankpost @@ -494,10 +2220,6 @@ def set_looping(self): NonStopDebateError.NSDNoMessagesError If there are no recorded messages to loop. - Returns - ------- - None. - """ if self._mode == NSDMode.LOOPING: @@ -531,6 +2253,21 @@ def set_looping(self): self._message_timer.unpause() def resume(self) -> NSDMode: + """ + Put the NSD in the mode it was before it entered intermission and return such mode. + + Raises + ------ + NonStopDebateError.NSDNotInModeError + If the NSD is not in intermission. + + Returns + ------- + NSDMode + Mode. + + """ + if self._mode not in [NSDMode.INTERMISSION, NSDMode.INTERMISSION_POSTBREAK, NSDMode.INTERMISSION_TIMERANOUT]: raise NonStopDebateError.NSDNotInModeError @@ -543,74 +2280,6 @@ def resume(self) -> NSDMode: raise RuntimeError(f'Should not have made it here for NSD {self}: ' f'{self._preintermission_mode}') - def add_player(self, user: ClientManager.Client): - """ - Make a user a player of the game. By default this player will not be a leader. It will - also subscribe the game ot the player so it can listen to its updates. - - It will also send a gamemode change order to the new player that aligns with the current - mode of the NSD. - - Parameters - ---------- - user : ClientManager.Client - User to add to the game. They must be in an area part of the game. - - Raises - ------ - GameError.GameIsUnmanagedError - If the game was scheduled for deletion and thus does not accept any mutator - public method calls. - GameWithAreasError.UserNotInAreaError - If the user is not in an area part of the game. - GameError.UserHasNoCharacterError - If the user has no character but the game requires that all players have characters. - GameError.UserNotInvitedError - If the game requires players be invited to be added and the user is not invited. - GameError.UserAlreadyPlayerError - If the user to add is already a user of the game. - GameError.UserHitGameConcurrentLimitError - If the player has reached any of the games it belongs to managed by this game's - manager concurrent player membership limit, or by virtue of joining this game they - will violate this game's concurrent player membership limit. - GameError.GameIsFullError - If the game reached its player limit. - - """ - - # print('NSD adding', user) - super().add_player(user) - - self.introduce_user(user) - - def remove_player(self, user: ClientManager.Client): - """ - Make a user be no longer a player of this game. If they were part of a team managed by - this game, they will also be removed from said team. It will also unsubscribe the game - from the player so it will no longer listen to its updates. It will also send an order to - the player to go back to its default theme gamemode. - - If the game required that there it always had players and by calling this method the - game had no more players, the game will automatically be scheduled for deletion. - - Parameters - ---------- - user : ClientManager.Client - User to remove. - - Raises - ------ - GameError.GameIsUnmanagedError - If the game was scheduled for deletion and thus does not accept any mutator - public method calls. - GameError.UserNotPlayerError - If the user to remove is already not a player of this game. - - """ - - super().remove_player(user) - self.dismiss_user(user) - def accept_break(self) -> bool: """ Accepts a break and increases the breaker's influence by 0.5, provided they are still a @@ -630,10 +2299,10 @@ def accept_break(self) -> bool: """ - if not self._mode == NSDMode.INTERMISSION_POSTBREAK: + if self._mode != NSDMode.INTERMISSION_POSTBREAK: raise NonStopDebateError.NSDNotInModeError - is_player = self._server.is_client(self._breaker) and self.is_player(self._breaker) + is_player = self.server.is_client(self._breaker) and self.is_player(self._breaker) if is_player: self._breaker.send_ooc('Your break was accepted and you recovered 0.5 influence.') self.get_trial().change_influence_by(self._breaker, 0.5) @@ -660,10 +2329,10 @@ def reject_break(self) -> bool: """ - if not self._mode == NSDMode.INTERMISSION_POSTBREAK: + if self._mode != NSDMode.INTERMISSION_POSTBREAK: raise NonStopDebateError.NSDNotInModeError - is_player = self._server.is_client(self._breaker) and self.is_player(self._breaker) + is_player = self.server.is_client(self._breaker) and self.is_player(self._breaker) if is_player: self._breaker.send_ooc('Your break was rejected and you lost 1 influence.') self.get_trial().change_influence_by(self._breaker, -1) @@ -673,13 +2342,15 @@ def reject_break(self) -> bool: return is_player - def destroy(self): + def unchecked_destroy(self): """ - Mark this game as destroyed and notify its manager so that it is deleted. - If the game is already destroyed, this function does nothing. + Mark this nonstop debate as destroyed and notify its manager so that it is deleted. + If the nonstop debate is already destroyed, this function does nothing. This method is reentrant (it will do nothing though). + This method does not assert structural integrity. + Returns ------- None. @@ -690,7 +2361,7 @@ def destroy(self): areas = self.get_areas() # Then carry on - super().destroy() + super().unchecked_destroy() # Force every user in the former areas of the trial to be dismissed for area in areas: @@ -723,7 +2394,7 @@ def setup_timers(self): def _refresh(): # print(f'NSD refreshed the timer for everyone at {time.time()}.') for user in self.get_users_in_areas(): - self._update_player_timer(user) + self._send_current_timers(user) self._player_refresh_timer._on_max_end = _refresh @@ -747,7 +2418,7 @@ def _mode_switch_lockout_unlock(): self._timer._on_min_end = functools.partial( self._set_intermission_timeranout, blankpost=True) - def _update_player_timer(self, player: ClientManager.Client): + def _send_current_timers(self, player: ClientManager.Client): if not self._timer: player.send_timer_set_time(timer_id=self._client_timer_id, new_time=0) player.send_timer_set_step_length(timer_id=self._client_timer_id, @@ -762,40 +2433,11 @@ def _update_player_timer(self, player: ClientManager.Client): player.send_timer_set_firing_interval(timer_id=self._client_timer_id, new_firing_interval=round(0.016*1000)) - def _on_area_client_inbound_ms_check(self, area: AreaManager.Area, - client: ClientManager.Client = None, - contents: Dict[str, Any] = None): - """ - Check if any of the following situations occur: - * If the user is not part of the nonstop debate. - - If none of the above is true, allow the IC message as is. - - Parameters - ---------- - area : AreaManager.Area - Area of the user that wants to send the IC message. - client : ClientManager.Client - Client that wants to send the IC message (possibly not a player of the nonstop debate). - contents : dict of str to Any - Arguments of the IC message as indicated in AOProtocol. - - Raises - ------ - ClientError - If any of the above disquaLifying situations is true. - - Returns - ------- - None. - - """ - - if not self.is_player(client): - raise ClientError('You are not a player of this nonstop debate.') - - def _on_client_inbound_ms_check(self, player: ClientManager.Client, - contents: Dict[str, Any] = None): + def _on_client_inbound_ms_check( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): """ Check if any of the following situations occur: They want to send a message... * Within 5 seconds of the mode being set to recording or intermission and not leader. @@ -841,8 +2483,11 @@ def _on_client_inbound_ms_check(self, player: ClientManager.Client, func = lambda c: 8 if c in {player}.union(self.get_leaders()) else 7 contents['PER_CLIENT_button'] = func - def _on_client_inbound_ms_final(self, player: ClientManager.Client, - contents: Dict[str, Any] = None): + def _on_client_inbound_ms_final( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): """ Add message of player to record of messages. @@ -898,11 +2543,138 @@ def _on_client_inbound_ms_final(self, player: ClientManager.Client, self._break_loop(player, contents) else: raise RuntimeError(f'Unrecognized mode {self._mode}') + self.manager._check_structure() + + def _on_client_change_character( + self, + player: ClientManager.Client, + old_char_id: int = -1, + old_char_name: str = '', + new_char_id: int = -1, + new_char_name: str = '', + ): + """ + It checks if the player is now no longer having a participant character. If that is + the case and the NSD requires all players have participant characters, the player is + automatically removed. + + Note that it may not necessarily be the case that the following hold: + 1. `old_char_name == player.hub.character_manager.get_character_name(old_char_id)`. + 2. `new_char_name == player.hub.character_manager.get_character_name(new_char_id)`. + This can occur for example if the character list changes, which prompts the player to + change character. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has changed character. + old_char_id : int, optional + Previous character ID. The default is -1. + old_char_name : str, optional + Previous character name. The default is the empty string. + new_char_id : int, optional + New character ID. The default is -1. + new_char_name : int, optional + New character name. The default is the empty string. + + Returns + ------- + None. + + """ + + if self.requires_participant_characters() and not player.has_participant_character(): + player.send_ooc('You were removed from your NSD as it required its players to have ' + 'participant characters.') + player.send_ooc_others(f'(X) Player {player.id} changed character from {old_char_name} ' + f'to a non-participant character and was thus removed from your ' + f'NSD.', + pred=lambda c: c in self.get_leaders()) + + nonplayers = self.get_nonplayer_users_in_areas() + nid = self.get_id() + + try: + self.remove_player(player) + except NonStopDebateError: + # NonStopDebateError may be raised because the parent trial may have already + # removed the player, and thus called remove_player. + # We use a general NonStopDebateError as it could be + # the case the NSD is scheduled for deletion or the user is already not a player. + pass + + if self.is_unmanaged(): + player.send_ooc(f'Your nonstop debate `{nid}` was automatically ' + f'ended as it lost all its players.') + player.send_ooc_others(f'(X) Nonstop debate `{nid}` was automatically ' + f'ended as it lost all its players.', + is_zstaff_flex=True, not_to=nonplayers) + player.send_ooc_others('The nonstop debate you were watching was automatically ' + 'ended as it lost all its players.', + is_zstaff_flex=False, part_of=nonplayers) + else: + player.send_ooc_others(f'(X) Player {player.id} changed character from {old_char_name} ' + f'to {player.get_char_name()} in your NSD.', + pred=lambda c: c in self.get_leaders()) + + self.manager._check_structure() + + def _on_client_destroyed(self, player: ClientManager.Client): + """ + Remove the player from the NSD. If the NSD is already unmanaged or + the player is not in the nonstop debate, this callback does nothing. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it was destroyed. + + Returns + ------- + None. + + """ + + if self.is_unmanaged(): + return + if player not in self.get_players(): + return + + player.send_ooc_others(f'(X) Player {player.displayname} of your nonstop debate ' + f'disconnected ({player.area.id}).', + pred=lambda c: c in self.get_leaders()) + + nonplayers = self.get_nonplayer_users_in_areas() + nid = self.get_id() + + try: + self.remove_player(player) + except NonStopDebateError: + # NonStopDebateError may be raised because the parent trial may have already + # removed the player, and thus called remove_player. + # We use a general NonStopDebateError as it could be + # the case the NSD is scheduled for deletion or the user is already not a player. + pass + + if self.is_unmanaged(): + # We check again, because now the NSD may be unmanaged + player.send_ooc_others(f'(X) Nonstop debate `{nid}` was automatically ' + f'ended as it lost all its players.', + is_zstaff_flex=True, not_to=nonplayers) + player.send_ooc_others('The nonstop debate you were watching was automatically ended ' + 'as it lost all its players.', + is_zstaff_flex=False, part_of=nonplayers) - def _on_area_client_left_final(self, area: AreaManager.Area, - client: ClientManager.Client = None, - old_displayname: str = None, ignore_bleeding: bool = False, - ignore_autopass: bool = False): + self.manager._check_structure() + + def _on_area_client_left_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False + ): """ If a player left to an area not part of the NSD, remove the player and warn them and the leaders of the NSD. @@ -940,7 +2712,7 @@ def _on_area_client_left_final(self, area: AreaManager.Area, client.send_ooc_others(f'(X) Player {old_displayname} [{client.id}] has left to ' f'an area not part of your NSD and thus was ' f'automatically removed from it ({area.id}->{client.area.id}).', - pred=lambda c: c in self.get_leaders()) + pred=lambda c: c in self.get_leaders(), in_hub=area.hub) nonplayers = self.get_nonplayer_users_in_areas() nid = self.get_id() @@ -952,10 +2724,10 @@ def _on_area_client_left_final(self, area: AreaManager.Area, f'ended as it lost all its players.') client.send_ooc_others(f'(X) Nonstop debate `{nid}` was automatically ' f'ended as it lost all its players.', - is_zstaff_flex=True, not_to=nonplayers) + is_zstaff_flex=True, not_to=nonplayers, in_hub=area.hub) client.send_ooc_others('The nonstop debate you were watching was automatically ' 'ended as it lost all its players.', - is_zstaff_flex=False, part_of=nonplayers) + is_zstaff_flex=False, part_of=nonplayers, in_hub=area.hub) else: client.send_ooc(f'You have left to an area not part of NSD ' @@ -963,16 +2735,20 @@ def _on_area_client_left_final(self, area: AreaManager.Area, client.send_ooc_others(f'(X) Player {old_displayname} [{client.id}] has left to an ' f'area not part of your NSD ' f'({area.id}->{client.area.id}).', - pred=lambda c: c in self.get_leaders()) + pred=lambda c: c in self.get_leaders(), in_hub=area.hub) self.dismiss_user(client) - self._check_structure() - def _on_area_client_entered_final(self, area: AreaManager.Area, - client: ClientManager.Client = None, - old_area: AreaManager.Area = None, - old_displayname: str = None, - ignore_bleeding: bool = False, - ignore_autopass: bool = False): + self.manager._check_structure() + + def _on_area_client_entered_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_area: Union[AreaManager.Area, None] = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False + ): """ If a non-player entered, warn them and the leaders of the NSD. @@ -983,7 +2759,8 @@ def _on_area_client_entered_final(self, area: AreaManager.Area, client : ClientManager.Client, optional The client that has entered. The default is None. old_area : AreaManager.Area - The old area the client has come from. The default is None. + The old area the client has come from (possibly None for a newly connected user). The + default is None. old_displayname : str, optional The old displayed name of the client before they changed area. This will typically change only if the client's character or showname are taken. The default is None. @@ -1002,10 +2779,11 @@ def _on_area_client_entered_final(self, area: AreaManager.Area, return if client not in self.get_players(): + old_area_id = str(old_area.id) if old_area else "SERVER_SELECT" client.send_ooc(f'You have entered an area part of NSD `{self.get_id()}`.') client.send_ooc_others(f'(X) Non-player {client.displayname} [{client.id}] has entered ' f'an area part of your NSD ' - f'({old_area.id}->{area.id}).', + f'({old_area_id}->{area.id}).', pred=lambda c: c in self.get_leaders()) if not self.get_trial().is_player(client): if client.is_staff(): @@ -1015,7 +2793,7 @@ def _on_area_client_entered_final(self, area: AreaManager.Area, f'Add them to your trial first before attempting to add ' f'them to your NSD.', pred=lambda c: c in self.get_leaders()) - elif not self._require_character or client.has_character(): + elif not self.requires_participant_characters() or client.has_participant_character(): if client.is_staff(): client.send_ooc(f'Join this NSD with /nsd_join {self.get_id()}') client.send_ooc_others(f'(X) Add {client.displayname} to your NSD with ' @@ -1023,81 +2801,44 @@ def _on_area_client_entered_final(self, area: AreaManager.Area, pred=lambda c: c in self.get_leaders()) else: if client.is_staff(): - client.send_ooc(f'This NSD requires you have a character to join. Join this ' - f'NSD with /nsd_join {self.get_id()} after choosing a ' - f'character.') - client.send_ooc_others(f'(X) This NSD requires players have a character to join. ' + client.send_ooc(f'This NSD requires you have a participant character to join. ' + f'Join this NSD with /nsd_join {self.get_id()} after choosing ' + f'a participant character.') + client.send_ooc_others(f'(X) This NSD requires players have a participant ' + f'character to join. ' f'Add {client.displayname} to your NSD with ' - f'/nsd_add {client.id} after they choose a character.', + f'/nsd_add {client.id} after they choose a participant ' + f'character.', pred=lambda c: c in self.get_leaders()) self.introduce_user(client) - def _on_client_change_character(self, player: ClientManager.Client, old_char_id: int = None, - new_char_id: int = None): - """ - It checks if the player is now no longer having a character. If that is - the case and the NSD requires all players have characters, the player is automatically - removed. - - Parameters - ---------- - player : ClientManager.Client - Player that signaled it has changed character. - old_char_id : int, optional - Previous character ID. The default is None. - new_char_id : int, optional - New character ID. The default is None. - - Returns - ------- - None. + self.manager._check_structure() + def _on_area_client_inbound_ms_check( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + contents: Dict[str, Any] = None + ): """ + Check if any of the following situations occur: + * If the user is not part of the nonstop debate. - old_char = player.get_char_name(old_char_id) - if self._require_character and not player.has_character(): - player.send_ooc('You were removed from your NSD as it required its players to have ' - 'characters.') - player.send_ooc_others(f'(X) Player {player.id} changed character from {old_char} to a ' - f'non-character and was removed from your NSD.', - pred=lambda c: c in self.get_leaders()) - - nonplayers = self.get_nonplayer_users_in_areas() - nid = self.get_id() - - try: - self.remove_player(player) - except GameError: - # GameErrors may be raised because the parent trial may have already removed the - # player, and thus called remove_player. We use a general GameError as it could be - # the case the NSD is scheduled for deletion or the user is already not a player. - pass - - if self.is_unmanaged(): - player.send_ooc(f'Your nonstop debate `{nid}` was automatically ' - f'ended as it lost all its players.') - player.send_ooc_others(f'(X) Nonstop debate `{nid}` was automatically ' - f'ended as it lost all its players.', - is_zstaff_flex=True, not_to=nonplayers) - player.send_ooc_others('The nonstop debate you were watching was automatically ' - 'ended as it lost all its players.', - is_zstaff_flex=False, part_of=nonplayers) - else: - player.send_ooc_others(f'(X) Player {player.id} changed character from {old_char} to ' - f'{player.get_char_name()} in your NSD.', - pred=lambda c: c in self.get_leaders()) - - self._check_structure() - - def _on_client_destroyed(self, player: ClientManager.Client): - """ - Remove the player from the NSD. If the NSD is already unmanaged or - the player is not in the game, this callback does nothing. + If none of the above is true, allow the IC message as is. Parameters ---------- - player : ClientManager.Client - Player that signaled it was destroyed. + area : AreaManager.Area + Area of the user that wants to send the IC message. + client : ClientManager.Client + Client that wants to send the IC message (possibly not a player of the nonstop debate). + contents : dict of str to Any + Arguments of the IC message as indicated in AOProtocol. + + Raises + ------ + ClientError + If any of the above disquaLifying situations is true. Returns ------- @@ -1105,36 +2846,8 @@ def _on_client_destroyed(self, player: ClientManager.Client): """ - if self.is_unmanaged(): - return - if player not in self.get_players(): - return - - player.send_ooc_others(f'(X) Player {player.displayname} of your nonstop debate ' - f'disconnected ({player.area.id}).', - pred=lambda c: c in self.get_leaders()) - - nonplayers = self.get_nonplayer_users_in_areas() - nid = self.get_id() - - try: - self.remove_player(player) - except GameError: - # GameErrors may be raised because the parent trial may have already removed the - # player, and thus called remove_player. We use a general GameError as it could be - # the case the NSD is scheduled for deletion or the user is already not a player. - pass - - if self.is_unmanaged(): - # We check again, because now the NSD may be unmanaged - player.send_ooc_others(f'(X) Nonstop debate `{nid}` was automatically ' - f'ended as it lost all its players.', - is_zstaff_flex=True, not_to=nonplayers) - player.send_ooc_others('The nonstop debate you were watching was automatically ended ' - 'as it lost all its players.', - is_zstaff_flex=False, part_of=nonplayers) - - self._check_structure() + if not self.is_player(client): + raise ClientError('You are not a player of this nonstop debate.') def _on_areas_loaded(self, area_manager: AreaManager): """ @@ -1330,7 +3043,7 @@ def __str__(self): def __repr__(self): """ - Return a representation of this game. + Return a representation of this nonstop debate. Returns ------- @@ -1339,19 +3052,23 @@ def __repr__(self): """ - return (f'NonStopDebate(server, {self._manager.get_id()}, "{self.get_id()}", ' - f'player_limit={self._playergroup._player_limit}, ' + return (f'NonStopDebate(server, {self.manager.get_id()}, "{self.get_id()}", ' + f'player_limit={self.get_player_limit()}, ' f'player_concurrent_limit={self.get_player_concurrent_limit()}, ' - f'require_players={self._playergroup._require_players}, ' - f'require_invitations={self._playergroup._require_invitations}, ' - f'require_leaders={self._playergroup._require_leaders}, ' - f'require_character={self._require_character}, ' - f'team_limit={self._team_manager.get_group_limit()}, ' + f'require_players={self.requires_players()}, ' + f'require_invitations={self.requires_invitations()}, ' + f'require_leaders={self.requires_leaders()}, ' + f'require_participant_character={self.requires_participant_characters()}, ' + f'team_limit={self._team_manager.get_managee_limit()}, ' f'timer_limit={self._timer_manager.get_timer_limit()}, ' f'areas={self.get_areas()}, ' - f'trial={self.get_trial().get_id()}) || ' + f'trial_id={self.get_trial().get_id()}), ' + f'mode={self._mode}' + f'|| ' f'players={self.get_players()}, ' f'invitations={self.get_invitations()}, ' f'leaders={self.get_leaders()}, ' f'timers={self.get_timers()}, ' - f'teams={self.get_teams()}') + f'teams={self.get_teams()}, ' + f'unmanaged={self.is_unmanaged()}), ' + f')') diff --git a/server/party_manager.py b/server/party_manager.py index 4e342e3be..15de584c5 100644 --- a/server/party_manager.py +++ b/server/party_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -21,6 +22,7 @@ import asyncio import random +from server.constants import Constants from server.exceptions import AreaError, ClientError, PartyError @@ -150,7 +152,8 @@ def check_lights(self): # Only call this when you are sure you want to cancel potential light timeout timers. # Restart light timer if self.lights_timeout is not None: - self.server.tasker.cancel_task(self.lights_timeout) + self.lights_timeout.cancel() + asyncio.ensure_future(Constants.await_cancellation(self.lights_timeout)) self.lights_timeout = None if not self.area.lights: @@ -248,7 +251,7 @@ def move_party(self, party, initiator, new_area): movers = self.check_move_party(party, initiator, new_area) moving, staying = movers[True], movers[False] - if not moving and staying: + if staying and not moving: raise PartyError('No one was able to move.') if moving and not staying: @@ -268,18 +271,17 @@ def move_party(self, party, initiator, new_area): return # Some people move, some stay behind case - """ - If initiator is not sneaking - 1. Visible who moved - 2. Visible who stayed as they were not allowed - 3. Sneaked who stayed - Party ID is assigned to the formed party that contains initiator - - If initiator is sneaking - 1. Sneaked who moved - 2. Sneaked who stayed as they were not allowed - 3. Visible who stayed (keeps party ID) - """ + + # If initiator is not sneaking + # 1. Visible who moved + # 2. Visible who stayed as they were not allowed + # 3. Sneaked who stayed + # Party ID is assigned to the formed party that contains initiator + + # If initiator is sneaking + # 1. Sneaked who moved + # 2. Sneaked who stayed as they were not allowed + # 3. Visible who stayed (keeps party ID) split = list() split.append({c: i for c, i in moving.items()}) # Guaranteed non-empty split.append({c: i for c, i in staying.items() if c.is_visible}) @@ -455,7 +457,7 @@ def check_move_party(self, party, initiator, new_area): culprit = member.displayname if member != initiator else 'yourself' raise ClientError('Unable to move the party due to {}.'.format(culprit)) - if error.code in ['ChArLocked', 'ChArGMLocked', 'ChArModLocked', + if error.code in ['ChArLocked', 'ChArModLocked', 'ChArRestrictedChar', 'ChArInArea']: movers[False][member] = new_char_id elif error.code is not None: diff --git a/server/playergroup_manager.py b/server/playergroup_manager.py index 491c8bac8..5117cfa98 100644 --- a/server/playergroup_manager.py +++ b/server/playergroup_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -17,16 +18,24 @@ # along with this program. If not, see . """ -Module that contains the PlayerGroupManager class and the PlayerGroup subclass. +Module that contains the PlayerGroupManager class and the _PlayerGroup subclass. """ +from __future__ import annotations + import random +import typing + +from typing import Callable, Dict, Tuple, Type, Union, Set -from server.constants import Constants from server.exceptions import PlayerGroupError -class PlayerGroup: +if typing.TYPE_CHECKING: + from server.client_manager import ClientManager + from server.tsuserver import TsuserverDR + +class _PlayerGroup: """ A mutable data type for player groups. @@ -37,58 +46,61 @@ class PlayerGroup: require that it never loses all its players as soon as it gets its first one (or else it is automatically deleted) and may require that if it has at least one player, then that there is at least one leader (or else one is automatically chosen between all players). - Each of these groups may also impose a concurrent player membership limit, so that every user - that is a player of it is at most a player of that many player groups managed by this - group's manager. + Each of these player groups may also impose a concurrent player membership limit, so that every + user that is a player of it is at most a player of that many player groups managed by this + player group's manager. - Once a group is scheduled for deletion, its manager will no longer recognize it as a group - it is managing (it will unmanage it), so no further mutator public method calls would be - allowed on the player group. + Once a player group is scheduled for deletion, its manager will no longer recognize it as a + player group it is managing (it will unmanage it), so no further mutator public method calls + would be allowed on the player group. + + Attributes + ---------- + server : TsuserverDR + Server the player group belongs to. + manager : PlayerGroupManager + Manager for this player group. """ # (Private) Attributes # -------------------- - # _server : TsuserverDR - # Server the player group belongs to. - # _manager : PlayerGroupManager - # Manager for this player group. # _playergroup_id : str # Identifier for this player group. - # _player_limit : int or None. - # If an int, it is the maximum number of players the group supports. If None, the group - # may have an arbitrary number of players. - # _player_concurrent_limit : int or None. + # _player_limit : Union[int, None]. + # If an int, it is the maximum number of players the player group supports. If None, the + # player group may have an arbitrary number of players. + # _player_concurrent_limit : Union[int, None]. # If an int, it is the maximum number of player groups managed by the same manager as - # this group that any player part of this player group may belong to, including this + # this player group that any player part of this player group may belong to, including this # player group. If None, no such restriction is considered. - # _players : set of ClientManager.Client + # _players : Set[ClientManager.Client] # Players of the player group. - # _leaders : set of ClientManager.Client + # _leaders : Set[ClientManager.Client] # Leaders of the player group. - # _invitations : set of clientManager.Client + # _invitations : Set[ClientManager.Client] # Users invited to (but not part of of) the player group. # _require_players : bool - # If True, the group will disassemble automatically if it loses all its players (but it - # may start with no players). + # If True, the player group will be destroyed automatically if it loses all its players (but + # it may start with no players). # _require_leaders : bool - # If True and the group has no leaders but at least one player, it will randomly choose - # one player to be a leader. + # If True and the player group has no leaders but at least one player, it will randomly + # choose one player to be a leader. # _ever_had_players : bool - # If True, at least once has a player been added successfully the the player group; + # If True, at least once has a player been added successfully the player group; # otherwise False. # _unmanaged : bool - # If True, the manager this group claims is its manager no longer recognizes it is - # managing this group, thus no further mutator public method calls would be allowed. + # If True, the manager this player group claims is its manager no longer recognizes it is + # managing this player group, thus no further mutator public method calls would be allowed. # Invariants # ---------- # 1. Each player is a client of the server. # 2. `self._unmanaged` is False if and only if `self` is in - # `self._manager._id_to_group.values()`. + # `self.manager.get_managees()`. # 3. If `self._unmanaged`, then `self._players`, `self._invitations`, `self._leaders` are # all empty sets. - # 4. For every player `player` in `self._players`, `self._manager._user_to_groups[player]` + # 4. For every player `player` in `self._players`, `self.manager.get_managees_of_user()[player]` # exists and contains `self`. # 5. If `self._player_limit` is not None, then `len(self._players) <= player_limit`. # 6. For every player in `self._leaders`, they also belong in `self._players`. @@ -99,9 +111,17 @@ class PlayerGroup: # 10. `self._invitations` and `self._players` are disjoint sets. # 11. If `self._require_invitations` is False, then `self._invitations` is the empty set. - def __init__(self, server, manager, playergroup_id, player_limit=None, - player_concurrent_limit=1, require_invitations=False, require_players=True, - require_leaders=True): + def __init__( + self, + server: TsuserverDR, + manager: PlayerGroupManager, + playergroup_id: str, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + ): """ Create a new player group. A player group should not be created outside some manager code. @@ -113,32 +133,32 @@ def __init__(self, server, manager, playergroup_id, player_limit=None, Manager for this player group. playergroup_id : str Identifier of the player group. - player_limit : int or None, optional + player_limit : Union[int, None], optional If an int, it is the maximum number of players the player group supports. If None, it indicates the player group has no player limit. Defaults to None. - player_concurrent_limit : int or None, optional + player_concurrent_limit : Union[int, None], optional If an int, it is the maximum number of player groups managed by `manager` that any - player of this group may belong to, including this group. If None, it indicates - that this group does not care about how many other player groups managed by - `manager` each of its players belongs to. Defaults to 1 (a player may not be in - another group managed by `manager` while in this group). + player of this player group may belong to, including this player group. If None, it + indicates that this player group does not care about how many other player groups + managed by `manager` each of its players belongs to. Defaults to 1 (a player may not be + in another player group managed by `manager` while in this player group). require_invitation : bool, optional - If True, players can only be added to the group if they were previously invited. If - False, no checking for invitations is performed. Defaults to False. + If True, players can only be added to the player group if they were previously invited. + If False, no checking for invitations is performed. Defaults to False. require_players : bool, optional - If True, if at any point the group has no players left, the group will + If True, if at any point the player group has no players left, the player group will automatically be deleted. If False, no such automatic deletion will happen. Defaults to True. require_leaders : bool, optional - If True, if at any point the group has no leaders left, the group will choose a - leader among any remaining players left; if no players are left, the next player - added will be made leader. If False, no such automatic assignment will happen. + If True, if at any point the player group has no leaders left, the player group will + choose a leader among any remaining players left; if no players are left, the next + player added will be made leader. If False, no such automatic assignment will happen. Defaults to True. """ - self._server = server - self._manager = manager + self.server = server + self.manager = manager self._playergroup_id = playergroup_id self._player_limit = player_limit self._player_concurrent_limit = player_concurrent_limit @@ -146,13 +166,14 @@ def __init__(self, server, manager, playergroup_id, player_limit=None, self._require_players = require_players self._require_leaders = require_leaders + self._name = playergroup_id self._players = set() self._leaders = set() self._invitations = set() self._ever_had_players = False self._unmanaged = False - def get_id(self): + def get_id(self) -> str: """ Return the ID of this player group. @@ -165,33 +186,117 @@ def get_id(self): return self._playergroup_id - def get_player_concurrent_limit(self): + def get_numerical_id(self) -> int: + """ + Return the numerical portion of the ID of this player group. + + Returns + ------- + int + Numerical portion of the ID. + """ + + digits = [x for x in self._playergroup_id if x.isdigit()] + number = ''.join(digits) + return int(number) + + def get_type_name(self) -> str: + """ + Return the type name of the player group. Names are fully lowercase. + Implementations of the class should replace this with a human readable name of the player + group. + + Returns + ------- + str + Type name of the player group. + + """ + + return "player group" + + def get_name(self) -> str: + """ + Get the name of the player group. + + Returns + ------- + str + Name. + """ + + return self._name + + def set_name(self, name: str): + """ + Set the name of the player group. + + Parameters + ---------- + name : str + Name. + """ + + self.unchecked_set_name(name) + self.manager._check_structure() + + def unchecked_set_name(self, name: str): + """ + Set the name of the player group. + + This method does not assert structural integrity. + + Parameters + ---------- + name : str + Name. + """ + + self._name = name + + def get_player_limit(self) -> Union[int, None]: + """ + Return the player membership limit of this player group. + + Returns + ------- + Union[int, None] + The player membership limit. + + """ + + return self._player_concurrent_limit + + def get_player_concurrent_limit(self) -> Union[int, None]: """ Return the concurrent player membership limit of this player group. Returns ------- - int or None + Union[int, None] The concurrent player membership limit. """ return self._player_concurrent_limit - def get_players(self, cond=None): + def get_players( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ Return (a shallow copy of) the set of players of this player group that satisfy a condition if given. Parameters ---------- - cond : types.LambdaType: ClientManager.Client -> bool, optional + cond : Callable[[ClientManager.Client, ], bool], optional Condition that all players returned satisfy. Defaults to None (no checked conditions). Returns ------- - set of ClientManager.Client + Set[ClientManager.Client] The (filtered) players of this player group. """ @@ -202,7 +307,7 @@ def get_players(self, cond=None): filtered_players = {player for player in self._players if cond(player)} return filtered_players - def is_player(self, user): + def is_player(self, user: ClientManager.Client) -> bool: """ Decide if a user is a player of the player group. @@ -220,10 +325,10 @@ def is_player(self, user): return user in self._players - def add_player(self, user): + def add_player(self, user: ClientManager.Client): """ Make a user a player of the player group. By default this player will not be a - leader, unless the group has no leaders and the player group requires a leader. + leader, unless the player group has no leaders and the player group requires a leader. Parameters ---------- @@ -233,20 +338,55 @@ def add_player(self, user): Raises ------ PlayerGroupError.GroupIsUnmanagedError: - If the group was scheduled for deletion and thus does not accept any mutator + If the player group was scheduled for deletion and thus does not accept any mutator public method calls. PlayerGroupError.UserNotInvitedError - If the group requires players be invited to be added and the user is not invited. + If the player group requires players be invited to be added and the user is not invited. PlayerGroupError.UserAlreadyPlayerError If the user to add is already a user of the player group. PlayerGroupError.UserInAnotherGroupError - If the player is already in another group managed by this manager. + If the player is already in another player group managed by this manager. + PlayerGroupError.UserHitGroupConcurrentLimitError + If the player has reached the concurrent player membership of any of the player groups + managed by the manager of this player group, or by virtue of joining this player group + they would violate this player group's concurrent player membership limit. PlayerGroupError.GroupIsFullError - If the group reached its player limit. - PlayerGroupError.UserHitGroupConcurrentLimitError. - If the player has reached any of the groups it belongs to managed by this player - group's manager concurrent player membership limit, or by virtue of joining this group - they will violate this group's concurrent player membership limit. + If the player group reached its player limit. + + """ + + self.unchecked_add_player(user) + self.manager._check_structure() + + def unchecked_add_player(self, user: ClientManager.Client): + """ + Make a user a player of the player group. By default this player will not be a + leader, unless the player group has no leaders and the player group requires a leader. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to add to the player group. + + Raises + ------ + PlayerGroupError.GroupIsUnmanagedError: + If the player group was scheduled for deletion and thus does not accept any mutator + public method calls. + PlayerGroupError.UserNotInvitedError + If the player group requires players be invited to be added and the user is not invited. + PlayerGroupError.UserAlreadyPlayerError + If the user to add is already a user of the player group. + PlayerGroupError.UserInAnotherGroupError + If the player is already in another player group managed by this manager. + PlayerGroupError.UserHitGroupConcurrentLimitError + If the player has reached the concurrent player membership of any of the player groups + managed by the manager of this player group, or by virtue of joining this player group + they would violate this player group's concurrent player membership limit. + PlayerGroupError.GroupIsFullError + If the player group reached its player limit. """ @@ -258,29 +398,53 @@ def add_player(self, user): raise PlayerGroupError.UserAlreadyPlayerError if self._player_limit is not None and len(self._players) >= self._player_limit: raise PlayerGroupError.GroupIsFullError - if self._manager.find_player_concurrent_limiting_group(user): + if self.manager.find_player_concurrent_limiting_managee(user): raise PlayerGroupError.UserHitGroupConcurrentLimitError - groups_of_user = self._manager.get_groups_of_user(user) + groups_of_user = self.manager.get_managees_of_user(user) if len(groups_of_user) >= self._player_concurrent_limit: raise PlayerGroupError.UserHitGroupConcurrentLimitError self._ever_had_players = True self._players.add(user) - self._manager._add_user_to_mapping(user, self) if self._require_invitations: self._invitations.remove(user) self._choose_leader_if_needed() - self._manager._check_structure() + def remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this player group. + + If the player group required that there it always had players and by calling this method the + group had no more players, the player group will automatically be scheduled for deletion. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + PlayerGroupError.GroupIsUnmanagedError: + If the player group was scheduled for deletion and thus does not accept any mutator + public method calls. + PlayerGroupError.UserNotPlayerError + If the user to remove is already not a player of this player group. - def remove_player(self, user): + """ + + self.unchecked_remove_player(user) + self.manager._check_structure() + + def unchecked_remove_player(self, user: ClientManager.Client): """ Make a user be no longer a player of this player group. - If the group required that there it always had players and by calling this method the - group had no more players, the group will automatically be scheduled for deletion. + If the player group required that there it always had players and by calling this method the + group had no more players, the player group will automatically be scheduled for deletion. + + This method does not assert structural integrity. Parameters ---------- @@ -290,10 +454,10 @@ def remove_player(self, user): Raises ------ PlayerGroupError.GroupIsUnmanagedError: - If the group was scheduled for deletion and thus does not accept any mutator + If the player group was scheduled for deletion and thus does not accept any mutator public method calls. PlayerGroupError.UserNotPlayerError - If the user to remove is already not a player of this group. + If the user to remove is already not a player of this player group. """ @@ -304,30 +468,42 @@ def remove_player(self, user): self._players.remove(user) self._leaders.discard(user) - self._manager._remove_user_from_mapping(user, self) # Check updated leadership requirement self._choose_leader_if_needed() # Check if no players, and disassemble if appropriate if self._require_players and not self._players: - self._manager.delete_group(self) + self.manager.delete_managee(self) - self._manager._check_structure() + def requires_players(self) -> bool: + """ + Return whether the player group requires players at all times. + + Returns + ------- + bool + Whether the player group requires players at all times. + """ - def get_invitations(self, cond=None): + return self._require_players + + def get_invitations( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ Return (a shallow copy of) the set of invited users of this player group that satisfy a condition if given. Parameters ---------- - cond : types.LambdaType: ClientManager.Client -> bool, optional + cond : Callable[[ClientManager.Client, ], bool], optional Condition that all invited users returned satisfy. Defaults to None (no checked conditions). Returns ------- - set of ClientManager.Client + Set[ClientManager.Client] The (filtered) invited users of this player group. """ @@ -338,7 +514,7 @@ def get_invitations(self, cond=None): filtered_invited = {invited for invited in self._invitations if cond(invited)} return filtered_invited - def is_invited(self, user): + def is_invited(self, user: ClientManager.Client) -> bool: """ Decide if a user is invited to the player group. @@ -350,7 +526,7 @@ def is_invited(self, user): Raises ------ PlayerGroupError.UserAlreadyPlayerError - If the user is a player of this group. + If the user is a player of this player group. Returns ------- @@ -364,7 +540,7 @@ def is_invited(self, user): return user in self._invitations - def add_invitation(self, user): + def add_invitation(self, user: ClientManager.Client): """ Mark a user as invited to this player group. @@ -376,10 +552,10 @@ def add_invitation(self, user): Raises ------ PlayerGroupError.GroupIsUnmanagedError: - If the group was scheduled for deletion and thus does not accept any mutator + If the player group was scheduled for deletion and thus does not accept any mutator public method calls. PlayerGroupError.GroupDoesNotTakeInvitationsError - If the group does not require users be invited to the player group. + If the player group does not require users be invited to the player group. PlayerGroupError.UserAlreadyInvitedError If the player to invite is already invited to the player group. PlayerGroupError.UserAlreadyPlayerError @@ -387,6 +563,33 @@ def add_invitation(self, user): """ + self.unchecked_add_invitation(user) + self.manager._check_structure() + + def unchecked_add_invitation(self, user: ClientManager.Client): + """ + Mark a user as invited to this player group. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to invite to the player group. + + Raises + ------ + PlayerGroupError.GroupIsUnmanagedError: + If the player group was scheduled for deletion and thus does not accept any mutator + public method calls. + PlayerGroupError.GroupDoesNotTakeInvitationsError + If the player group does not require users be invited to the player group. + PlayerGroupError.UserAlreadyInvitedError + If the player to invite is already invited to the player group. + PlayerGroupError.UserAlreadyPlayerError + If the player to invite is already a player of the player group. + + """ if self._unmanaged: raise PlayerGroupError.GroupIsUnmanagedError if not self._require_invitations: # By design check if invitations are required first @@ -398,12 +601,36 @@ def add_invitation(self, user): self._invitations.add(user) - self._manager._check_structure() + def remove_invitation(self, user: ClientManager.Client) -> bool: + """ + Mark a user as no longer invited to this player group (uninvite). + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + PlayerGroupError.GroupIsUnmanagedError: + If the player group was scheduled for deletion and thus does not accept any mutator + public method calls. + PlayerGroupError.GroupDoesNotTakeInvitationsError + If the player group does not require users be invited to the player group. + PlayerGroupError.UserNotInvitedError + If the user to uninvite is already not invited to this player group. - def remove_invitation(self, user): + """ + + self.unchecked_remove_invitation(user) + self.manager._check_structure() + + def unchecked_remove_invitation(self, user: ClientManager.Client): """ Mark a user as no longer invited to this player group (uninvite). + This method does not assert structural integrity. + Parameters ---------- user : ClientManager.Client @@ -412,12 +639,12 @@ def remove_invitation(self, user): Raises ------ PlayerGroupError.GroupIsUnmanagedError: - If the group was scheduled for deletion and thus does not accept any mutator + If the player group was scheduled for deletion and thus does not accept any mutator public method calls. PlayerGroupError.GroupDoesNotTakeInvitationsError - If the group does not require users be invited to the player group. + If the player group does not require users be invited to the player group. PlayerGroupError.UserNotInvitedError - If the user to uninvite is already not invited to this group. + If the user to uninvite is already not invited to this player group. """ @@ -430,8 +657,6 @@ def remove_invitation(self, user): self._invitations.remove(user) - self._manager._check_structure() - def requires_invitations(self) -> bool: """ Return True if the player group requires players be invited before being allowed to join @@ -446,20 +671,23 @@ def requires_invitations(self) -> bool: return self._require_invitations - def get_leaders(self, cond=None): + def get_leaders( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ Return (a shallow copy of) the set of leaders of this player group that satisfy a condition if given. Parameters ---------- - cond : types.LambdaType: ClientManager.Client -> bool, optional + cond : Callable[[ClientManager.Client, ], bool], optional Condition that all leaders returned satisfy. Defaults to None (no checked conditions). Returns ------- - set of ClientManager.Client + Set[ClientManager.Client] The (filtered) leaders of this player group. """ @@ -470,20 +698,23 @@ def get_leaders(self, cond=None): filtered_leaders = {leader for leader in self._leaders if cond(leader)} return filtered_leaders - def get_regulars(self, cond=None): + def get_regulars( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ Return (a shallow copy of) the set of players of this player group that are not leaders (regulars) and satisfy a condition if given. Parameters ---------- - cond : types.LambdaType: ClientManager.Client -> bool, optional + cond : Callable[[ClientManager.Client, ], bool], optional Condition that all regulars returned satisfy. Defaults to None (no checked conditions). Returns ------- - set of ClientManager.Client + Set[ClientManager.Client] The (filtered) regulars of this player group. """ @@ -495,7 +726,7 @@ def get_regulars(self, cond=None): filtered_regulars = {regular for regular in regulars if cond(regular)} return filtered_regulars - def is_leader(self, user): + def is_leader(self, user: ClientManager.Client) -> bool: """ Decide if a user is a leader of the player group. @@ -507,7 +738,7 @@ def is_leader(self, user): Raises ------ PlayerGroupError.UserNotPlayerError - If the player to test is not a player of this group. + If the player to test is not a player of this player group. Returns ------- @@ -521,9 +752,9 @@ def is_leader(self, user): return user in self._leaders - def add_leader(self, user): + def add_leader(self, user: ClientManager.Client): """ - Set a user as leader of this group (promote to leader). + Set a user as leader of this player group (promote to leader). Parameters ---------- @@ -533,12 +764,38 @@ def add_leader(self, user): Raises ------ PlayerGroupError.GroupIsUnmanagedError: - If the group was scheduled for deletion and thus does not accept any mutator + If the player group was scheduled for deletion and thus does not accept any mutator public method calls. PlayerGroupError.UserNotPlayerError - If the player to promote is not a player of this group. + If the player to promote is not a player of this player group. PlayerGroupError.UserAlreadyLeaderError - If the player to promote is already a leader of this group. + If the player to promote is already a leader of this player group. + + """ + + self.unchecked_add_leader(user) + self.manager._check_structure() + + def unchecked_add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this player group (promote to leader). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + PlayerGroupError.GroupIsUnmanagedError: + If the player group was scheduled for deletion and thus does not accept any mutator + public method calls. + PlayerGroupError.UserNotPlayerError + If the player to promote is not a player of this player group. + PlayerGroupError.UserAlreadyLeaderError + If the player to promote is already a leader of this player group. """ @@ -550,11 +807,10 @@ def add_leader(self, user): raise PlayerGroupError.UserAlreadyLeaderError self._leaders.add(user) - self._manager._check_structure() - def remove_leader(self, user): + def remove_leader(self, user: ClientManager.Client): """ - Make a user no longer leader of this group (demote). + Make a user no longer leader of this player group (demote). Parameters ---------- @@ -564,12 +820,38 @@ def remove_leader(self, user): Raises ------ PlayerGroupError.GroupIsUnmanagedError: - If the group was scheduled for deletion and thus does not accept any mutator + If the player group was scheduled for deletion and thus does not accept any mutator public method calls. PlayerGroupError.UserNotPlayerError - If the player to demote is not a player of this group. + If the player to demote is not a player of this player group. PlayerGroupError.UserNotLeaderError - If the player to demote is already not a leader of this group. + If the player to demote is already not a leader of this player group. + + """ + + self.unchecked_remove_leader(user) + self.manager._check_structure() + + def unchecked_remove_leader(self, user: ClientManager.Client): + """ + Make a user no longer leader of this player group (demote). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to demote. + + Raises + ------ + PlayerGroupError.GroupIsUnmanagedError: + If the player group was scheduled for deletion and thus does not accept any mutator + public method calls. + PlayerGroupError.UserNotPlayerError + If the player to demote is not a player of this player group. + PlayerGroupError.UserNotLeaderError + If the player to demote is already not a leader of this player group. """ @@ -583,9 +865,20 @@ def remove_leader(self, user): self._leaders.remove(user) # Check leadership requirement self._choose_leader_if_needed() - self._manager._check_structure() - def is_unmanaged(self): + def requires_leaders(self) -> bool: + """ + Return whether the player group requires leaders at all times. + + Returns + ------- + bool + Whether the player group requires leaders at all times. + """ + + return self._require_leaders + + def is_unmanaged(self) -> bool: """ Return True if this player group is unmanaged, False otherwise. @@ -598,7 +891,7 @@ def is_unmanaged(self): return self._unmanaged - def has_ever_had_players(self): + def has_ever_had_players(self) -> bool: """ Return True if a player has ever been added to this player group, False otherwise. @@ -624,6 +917,23 @@ def destroy(self): """ + self.unchecked_destroy() + self.manager._check_structure() + self._check_structure() # Manager will not check this otherwise. + + def unchecked_destroy(self): + """ + Mark this player group as destroyed and notify its manager so that it is deleted. + If the player group is already destroyed, this function does nothing. + + This method is reentrant (it will do nothing though). + + Returns + ------- + None. + + """ + # Implementation detail: To make this safely reentrant and allow this code to be ran # multiple times, we do the following: # At the very beginning we check if self._unmanaged is True. @@ -635,25 +945,23 @@ def destroy(self): return self._unmanaged = True - try: - self._manager.delete_group(self) - except PlayerGroupError.ManagerDoesNotManageGroupError: - pass + if self.manager.manages_managee(self): + # If manager still recognizes, remove + self.manager.unchecked_delete_managee(self) + # Don't use errors because exceptions thrown may not be PlayerGroupError # While only clearing internal variables here means that structural integrity won't be # maintained in time for the manager's structural checks, as the manager will no longer - # list this player group as a group it manages, no structural checks will be performed + # list this player group as a managee, no structural checks will be performed # on this object anymore by outside entities, so this code is safe. self._players = set() self._invitations = set() self._leaders = set() - return - def _choose_leader_if_needed(self): """ - If the player group requires that the group always have a leader if there is at least + If the player group requires that the player group always have a leader if there is at least one player, one leader will randomly be chosen among all players. If this condition is already true, no new leaders are chosen. """ @@ -666,12 +974,18 @@ def _choose_leader_if_needed(self): return new_leader = random.choice(list(self.get_players())) - self.add_leader(new_leader) + self.unchecked_add_leader(new_leader) def _check_structure(self): """ Assert that all invariants specified in the class description are maintained. + Parameters + ---------- + do_earliest_ancestor_check : bool, optional + If True, the structural checks will only be performed if the instance is of type this + class rather than some inherited class. If False, this pre-emptive check is ignored. + Defaults to True. Raises ------ AssertionError @@ -681,92 +995,98 @@ def _check_structure(self): # 1. for player in self._players: - err = (f'For group {self._playergroup_id}, expected that player {player} was a ' - f'client of its server {self._server}, but found that was not the case. ' - f'|| {self}') - assert self._server.is_client(player), err + assert self.server.is_client(player), ( + f'For player group {self._playergroup_id}, expected that player {player} was a ' + f'client of its server {self.server}, but found that was not the case. || {self}' + ) # 2. - err = (f'For group {self._playergroup_id} that is not unmanaged that also claims that ' - f'it is managed by manager {self._manager}, expected that it recognized that ' - f'it managed it, but found it did not. || {self}') - if not self._unmanaged: - assert self in self._manager.get_groups(), err - - err = (f'For group {self._playergroup_id} that is unmanaged that also claims that it ' - f'was managed by manager {self._manager}, expected that it recognized that ' - f'it did not manage it, but found it did. || {self}') - if self._unmanaged: - assert self not in self._manager.get_groups(), err + assert self._unmanaged or self in self.manager.get_managees(), ( + f'For player group {self._playergroup_id} that is not unmanaged that also claims ' + f'that it is managed by manager {self.manager}, expected that it recognized that ' + f'it managed it, but found it did not. || {self}' + ) # 3. if self._unmanaged: - err = (f'For group {self._playergroup_id} that is unmanaged, expected that it had ' - f'no players, but found it had these players: {self._players} || {self}') - assert not self._players, err + assert not self._players, ( + f'For player group {self._playergroup_id} that is unmanaged, expected that it had ' + f'no players, but found it had these players: {self._players} || {self}' + ) - err = (f'For group {self._playergroup_id} that is unmanaged, expected that it had ' - f'no invitations, but found it had these invitations: {self._invitations} ' - f'|| {self}') - assert not self._invitations, err + assert not self._invitations, ( + f'For player group {self._playergroup_id} that is unmanaged, expected that it had ' + f'no invitations, but found it had these invitations: {self._invitations} ' + f'|| {self}') - err = (f'For group {self._playergroup_id} that is unmanaged, expected that it had ' - f'no leaders, but found it had these leaders: {self._leaders} || {self}') - assert not self._leaders, err + assert not self._leaders, ( + f'For player group {self._playergroup_id} that is unmanaged, expected that it had ' + f'no leaders, but found it had these leaders: {self._leaders} || {self}' + ) # 4. for player in self._players: - err = (f'For group {self._playergroup_id}, expected that its player {player} is ' - f'properly recognized in the player to group mapping of the manager of the ' - f'group {self._manager}, but found that was not the case. || {self}') - assert (player in self._manager.get_users_in_groups() - and self in self._manager.get_groups_of_user(player)), err + assert ( + player in self.manager.get_users_in_some_managee() + and self in self.manager.get_managees_of_user(player) + ), ( + f'For player group {self._playergroup_id}, expected that its player {player} is ' + f'properly recognized in the player to player group mapping of the manager of ' + f'the player group {self.manager}, but found that was not the case. || {self}' + ) # 5. if self._player_limit is not None: - err = (f'For group {self._playergroup_id}, expected that there were at most ' - f'{self._player_limit} players, but found it had {len(self._players)} ' - f'players. || {self}') - assert len(self._players) <= self._player_limit, err + assert len(self._players) <= self._player_limit, ( + f'For player group {self._playergroup_id}, expected that there were at most ' + f'{self._player_limit} players, but found it had {len(self._players)} players. ' + f'|| {self}' + ) # 6. for leader in self._leaders: - err = (f'For group {self._playergroup_id}, expected that leader {leader} was a ' - f'player of it too, but found it was not. || {self}') - assert leader in self._players, err + assert leader in self._players, ( + f'For player group {self._playergroup_id}, expected that leader {leader} was a ' + f'player of it too, but found it was not. || {self}' + ) # 7. if self._players: - err = (f'For group {self._playergroup_id}, expected it knew it ever had some ' - f'players, but found it did not. || {self}') - assert self._ever_had_players, err + assert self._ever_had_players, ( + f'For player group {self._playergroup_id}, expected it knew it ever had some ' + f'players, but found it did not. || {self}' + ) # 8. if self._require_players and self._ever_had_players: - err = (f'For group {self._playergroup_id}, expected that it was scheduled for ' - f'deletion after losing all its players, but found it was not. || {self}') - assert self._players or self._unmanaged, err + assert self._players or self._unmanaged, ( + f'For player group {self._playergroup_id}, expected that it was scheduled for ' + f'deletion after losing all its players, but found it was not. || {self}' + ) # 9. if self._require_leaders: - err = (f'For group {self._playergroup_id} with some players, expected that there ' - f'was a leader, but found it had none. || {self}') - assert not self._players or self._leaders, err + assert not self._players or self._leaders, ( + f'For player group {self._playergroup_id} with some players, expected that ' + f'there was a leader, but found it had none. || {self}' + ) # 10. players_also_invited = self._players.intersection(self._invitations) - err = (f'For group {self._playergroup_id}, expected that all users in the invitation ' - f'list of the group were not players, but found the following players who were ' - f'in the invitation list: {players_also_invited}. || {self}') - assert not players_also_invited, err + assert not players_also_invited, ( + f'For player group {self._playergroup_id}, expected that all users in the ' + f'invitation list of the player group were not players, but found the following ' + f'players who were in the invitation list: {players_also_invited}. || {self}' + ) # 11. - err = (f'For group {self._playergroup_id} that does not require invitations, expected ' - f'that no player was invited to the group, but found the following users who ' - f'were in the invitation list: {self._invitations}. || {self}') - assert self._require_invitations or not self._invitations + assert self._require_invitations or not self._invitations, ( + f'For player group {self._playergroup_id} that does not require invitations, ' + f'expected that no player was invited to the player group, but found the following ' + f'users who were in the invitation list: {self._invitations}. || {self}' + ) - def __repr__(self): + def __repr__(self) -> str: """ Return a representation of this player group. @@ -777,16 +1097,19 @@ def __repr__(self): """ - return (f"PlayerGroup(server, {self._manager.get_id()}, '{self._playergroup_id}', " - f"player_limit={self._player_limit}, " - f"player_concurrent_limit={self._player_concurrent_limit}, " - f"require_players={self._require_players}, " - f"require_invitations={self._require_invitations}, " - f"require_leaders={self._require_leaders}) " - f"|| " - f"players={self._players}, invitations={self._invitations}, " - f"leaders={self._leaders}, unmanaged={self._unmanaged}" - ) + return (f'PlayerGroup(server, {self.get_id()}, "{self.get_id()}", ' + f'player_limit={self.get_player_limit()}, ' + f'player_concurrent_limit={self.get_player_concurrent_limit()}, ' + f'require_players={self.requires_players()}, ' + f'require_invitations={self.requires_invitations()}, ' + f'require_leaders={self.requires_leaders()}, ' + f'|| ' + f'players={self.get_players()}, ' + f'invitations={self.get_invitations()}, ' + f'leaders={self.get_leaders()}, ' + f'unmanaged={self.is_unmanaged()}), ' + f')') + class PlayerGroupManager: """ @@ -794,50 +1117,54 @@ class PlayerGroupManager: Each player group is managed by a player group manager. Only this manager is allowed to execute any public methods on them. Each manager may also have a player group limit (beyond which it - will not manage any more groups). + will not manage any more player groups). Contains the player group object definition, methods for creating and deleting them, as well as some observer methods. + Attributes + ---------- + server : TsuserverDR + Server the player group manager belongs to. + """ # (Private) Attributes # -------------------- - # _server : TsuserverDR - # Server the player group manager belongs to. - # _playergroup_limit : int or None + # _managee_limit : Union[int, None] # If an int, it is the maximum number of player groups this manager supports. If None, the - # manager may manage an arbitrary number of groups. - # _default_playergroup_type : PlayerGroup or functools.partial - # The type of player groups this player group manager will create by default when ordered + # manager may manage an arbitrary number of player groups. + # _default_managee_type : _PlayerGroup + # The type of player group this player group manager will create by default when ordered # to create a new one. - # _user_to_groups : dict of ClientManager.Client to set of PlayerGroup + # _user_to_managees : dict of ClientManager.Client to set of _PlayerGroup # Mapping of users to the player groups managed by this manager they belong to. - # _id_to_group : dict of str to PlayerGroup + # _id_to_managee : dict of str to _PlayerGroup # Mapping of player group IDs to player groups that this manager manages. # Invariants # ---------- - # 1. If `self._playergroup_limit` is an int, then `len(self._id_to_group) <= - # self._playergroup_limit`. - # 2. For every player group `(playergroup_id, playergroup)` in `self._id_to_group.items()`: + # 1. If `self._managee_limit` is an int, then `len(self._id_to_managee) <= + # self._managee_limit`. + # 2. For every player group `(playergroup_id, _PlayerGroup)` in `self._id_to_managee.items()`: # a. `playergroup._playergroup_id == playergroup_id`. - # b. `playergroup._players` is a subset of `self._user_to_groups.keys()`. - # c. `playergroup.is_unmanaged()` is False. - # 3. For all pairs of distinct groups `group1` and `group2` in `self._id_to_group.values()`: + # b. `playergroup.is_unmanaged()` is False. + # 3. For all pairs of distinct player groups `group1` and `group2` in + # `self._id_to_managee.values()`: # a. `group1._playergroup_id != group2._playergroup_id`. - # 4. For every player `player` in `self._user_to_groups.keys()`: - # a. `self._user_to_groups[player]` is a non-empty set. - # b. `self._user_to_groups[player]` is a subset of `self._id_to_group.values()`. - # c. For every group `group` in `self._user_to_groups[player]`, `player` belongs to `group`. - # 5. For every player `player` in `self._user_to_groups.keys()`: - # a. For every group `group` in `self._user_to_groups[player]`: - # 1. `group` has no player concurrent membership limit, or it is at least the length - # of `self._user_to_groups[player]`. - # 6. Each player group it manages also satisfies its structural invariants. - - def __init__(self, server, playergroup_limit=None, default_playergroup_type=None, - available_id_producer=None): + # 4. For every player and player groups pair (`player`, `playergroups`) in + # `self.get_managees_of_user().items()`: + # a. For every player group `playergroup` in `playergroups`: + # 1. `playergroup` has no player concurrent membership limit, or it is at least the + # length of `playergroups`. + # 5. Each player group it manages also satisfies its structural invariants. + + def __init__( + self, + server: TsuserverDR, + managee_limit: Union[int, None] = None, + default_managee_type: Type[_PlayerGroup] = None, + ): """ Create a player group manager object. @@ -845,136 +1172,258 @@ def __init__(self, server, playergroup_limit=None, default_playergroup_type=None ---------- server : TsuserverDR The server this player group manager belongs to. - playergroup_limit : int, optional - The maximum number of groups this manager can handle. Defaults to None (no limit). - playergroup_type : PlayerGroup, optional + managee_limit : int, optional + The maximum number of player groups this manager can handle. Defaults to None (no + limit). + default_managee_type : Type[_PlayerGroup], optional The default type of player group this manager will create. Defaults to None (and then - converted to PlayerGroup). - available_id_producer : typing.types.FunctionType, optional - Function to produce available group IDs. It will override the built-in class method - get_available_group_id. Defaults to None (and then converted to the built-in - get_available_group_id). + converted to _PlayerGroup). """ - if default_playergroup_type is None: - default_playergroup_type = PlayerGroup - if available_id_producer is None: - available_id_producer = self.get_available_group_id - self.get_available_group_id = available_id_producer + self._id = hex(id(self)) + + if default_managee_type is None: + default_managee_type = _PlayerGroup + + self.server = server + self._default_group_type = default_managee_type + self._group_limit = managee_limit + self._id_to_group: Dict[str, _PlayerGroup] = dict() + + def get_managee_type(self) -> Type[_PlayerGroup]: + """ + Return the type of the player group that will be constructed by default with a call of + `new_managee`. + + Returns + ------- + Type[_PlayerGroup] + Type of the player group. + """ + + return self._default_group_type + + def new_managee( + self, + managee_type: Type[_PlayerGroup] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + **kwargs, + ) -> _PlayerGroup: + """ + Create a new player group managed by this manager. + + Parameters + ---------- + managee_type : Type[_PlayerGroup], optional + Class of player group that will be produced. Defaults to None (and converted to the + default player group created by this player group manager). + creator : Union[ClientManager.Client, None], optional + The player who created this player group. If set, they will also be added to the player + group. Defaults to None. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the player group supports. If None, it + indicates the player group has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of player groups managed by `self` that any player + of this player group to create may belong to, including this player group to create. If + None, it indicates that this player group does not care about how many other player + groups managed by `self` each of its players belongs to. Defaults to 1 (a player may + not be in another player group managed by `self` while in this new player group). + require_invitations : bool, optional + If True, users can only be added to the player group if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the player group loses all its players, the player group will + automatically be deleted. If False, no such automatic deletion will happen. Defaults to + True. + require_leaders : bool, optional + If True, if at any point the player group has no leaders left, the player group will + choose a leader among any remaining players left; if no players are left, the next + player added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + **kwargs : Any + Additional arguments to consider when producing the player group. - self._server = server - self._default_playergroup_type = default_playergroup_type - self._playergroup_limit = playergroup_limit - self._id_to_group = dict() - self._user_to_groups = dict() + Returns + ------- + _PlayerGroup + The created player group. + Raises + ------ + PlayerGroupError.ManagerTooManyGroupsError + If the manager is already managing its maximum number of player groups. + PlayerGroupError.UserHitGroupConcurrentLimitError. + If `creator` has reached the concurrent player membership limit of any of the player + groups it belongs to managed by this manager, or by virtue of joining this player group + the creator they would violate this player group's concurrent player membership limit. + + """ + + playergroup = self.unchecked_new_managee( + managee_type=managee_type, + creator=creator, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + **kwargs, + ) self._check_structure() + return playergroup - def new_group(self, playergroup_type=None, creator=None, player_limit=None, - player_concurrent_limit=1, require_invitations=False, require_players=True, - require_leaders=True): + def unchecked_new_managee( + self, + managee_type: Type[_PlayerGroup] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + **kwargs, + ) -> _PlayerGroup: """ Create a new player group managed by this manager. Parameters ---------- - playergroup_type : PlayerGroup + managee_type : Type[_PlayerGroup], optional Class of player group that will be produced. Defaults to None (and converted to the default player group created by this player group manager). - creator : ClientManager.Client, optional - The player who created this group. If set, they will also be added to the group. - Defaults to None. - player_limit : int or None, optional + creator : Union[ClientManager.Client, None], optional + The player who created this player group. If set, they will also be added to the player + group. Defaults to None. + player_limit : Union[int, None], optional If an int, it is the maximum number of players the player group supports. If None, it indicates the player group has no player limit. Defaults to None. - player_concurrent_limit : int or None, optional + player_concurrent_limit : Union[int, None], optional If an int, it is the maximum number of player groups managed by `self` that any player - of this group to create may belong to, including this group to create. If None, it - indicates that this group does not care about how many other player groups managed by - `self` each of its players belongs to. Defaults to 1 (a player may not be in - another group managed by `self` while in this new group). + of this player group to create may belong to, including this player group to create. If + None, it indicates that this player group does not care about how many other player + groups managed by `self` each of its players belongs to. Defaults to 1 (a player may + not be in another player group managed by `self` while in this new player group). require_invitations : bool, optional - If True, users can only be added to the group if they were previously invited. If + If True, users can only be added to the player group if they were previously invited. If False, no checking for invitations is performed. Defaults to False. require_players : bool, optional - If True, if at any point the group loses all its players, the group will automatically - be deleted. If False, no such automatic deletion will happen. Defaults to True. + If True, if at any point the player group loses all its players, the player group will + automatically be deleted. If False, no such automatic deletion will happen. Defaults to + True. require_leaders : bool, optional - If True, if at any point the group has no leaders left, the group will choose a leader - among any remaining players left; if no players are left, the next player added will - be made leader. If False, no such automatic assignment will happen. Defaults to True. + If True, if at any point the player group has no leaders left, the player group will + choose a leader among any remaining players left; if no players are left, the next + player added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + **kwargs : Any + Additional arguments to consider when producing the player group. Returns ------- - PlayerGroup + _PlayerGroup The created player group. Raises ------ PlayerGroupError.ManagerTooManyGroupsError - If the manager is already managing its maximum number of groups. + If the manager is already managing its maximum number of player groups. PlayerGroupError.UserHitGroupConcurrentLimitError. - If `creator` has reached the concurrent player membership limit of any of the groups it - belongs to managed by this manager, or by virtue of joining this group the creator - they will violate this group's concurrent player membership limit. + If `creator` has reached the concurrent player membership limit of any of the player + groups it belongs to managed by this manager, or by virtue of joining this player group + the creator they would violate this player group's concurrent player membership limit. """ - if self._playergroup_limit is not None: - if len(self._id_to_group) >= self._playergroup_limit: - raise PlayerGroupError.ManagerTooManyGroupsError + if managee_type is None: + managee_type = self.get_managee_type() + + if not self.is_managee_creatable(): + raise PlayerGroupError.ManagerTooManyGroupsError if creator: - # Check if adding the creator to this new group would cause any concurrent + # Check if adding the creator to this new player group would cause any concurrent # membership limits being reached. - if self.find_player_concurrent_limiting_group(creator): + if self.find_player_concurrent_limiting_managee(creator): raise PlayerGroupError.UserHitGroupConcurrentLimitError - groups_of_user = self._user_to_groups.get(creator, None) + groups_of_user = self.get_managees_of_user(creator) if groups_of_user is not None and len(groups_of_user) >= player_concurrent_limit: raise PlayerGroupError.UserHitGroupConcurrentLimitError - # At this point, we are committed to creating this player group. - # Generate a playergroup ID and the new group + group_id = self.get_available_managee_id() - def_args = ( - self._server, + # At this point, we are committed to creating this player group. + playergroup = managee_type( + self.server, self, - self.get_available_group_id(), - ) - def_kwargs = { - 'player_limit': player_limit, - 'player_concurrent_limit': player_concurrent_limit, - 'require_invitations': require_invitations, - 'require_players': require_players, - 'require_leaders': require_leaders, - } - - new_playergroup_type = Constants.make_partial_from(playergroup_type, - self._default_playergroup_type, - *def_args, **def_kwargs) - playergroup = new_playergroup_type() - playergroup_id = playergroup.get_id() - self._id_to_group[playergroup_id] = playergroup + group_id, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + **kwargs, + ) - if creator: - playergroup.add_player(creator) + self._id_to_group[group_id] = playergroup + + try: + if creator: + playergroup.unchecked_add_player(creator) + except PlayerGroupError as ex: + # Discard player group + self.unchecked_delete_managee(playergroup) + raise ex - self._check_structure() return playergroup - def delete_group(self, playergroup): + def delete_managee(self, managee: _PlayerGroup) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a player group managed by this manager, so all its players no longer belong to this + player group. + + Parameters + ---------- + managee : _PlayerGroup + The player group to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the player group that was deleted. + + Raises + ------ + PlayerGroupError.ManagerDoesNotManageGroupError + If the manager does not manage the target player group. + + """ + + playergroup_id, former_players = self.unchecked_delete_managee(managee) + self._check_structure() + return playergroup_id, former_players + + def unchecked_delete_managee( + self, + managee: _PlayerGroup + ) -> Tuple[str, Set[ClientManager.Client]]: """ Delete a player group managed by this manager, so all its players no longer belong to this player group. Parameters ---------- - playergroup : PlayerGroup + managee : _PlayerGroup The player group to delete. Returns ------- - (str, set of ClientManager.Client) + Tuple[str, Set[ClientManager.Client]] The ID and players of the player group that was deleted. Raises @@ -984,31 +1433,25 @@ def delete_group(self, playergroup): """ - if not self.manages_group(playergroup): + if not self.manages_managee(managee): raise PlayerGroupError.ManagerDoesNotManageGroupError - playergroup_id = playergroup.get_id() + playergroup_id = managee.get_id() self._id_to_group.pop(playergroup_id) - former_players = playergroup.get_players() + former_players = managee.get_players() - for player in former_players: - self._user_to_groups[player].remove(playergroup) - if not self._user_to_groups[player]: - self._user_to_groups.pop(player) + managee.unchecked_destroy() - playergroup.destroy() - - self._check_structure() return playergroup_id, former_players - def manages_group(self, playergroup): + def manages_managee(self, managee: _PlayerGroup) -> bool: """ Return True if the player group is managed by this manager, False otherwise. Parameters ---------- - playergroup : PlayerGroup + managee : _PlayerGroup The player group to check. Returns @@ -1018,74 +1461,142 @@ def manages_group(self, playergroup): """ - return playergroup in self._id_to_group.values() + return managee in self._id_to_group.values() - def get_groups(self): + def get_managees(self) -> Set[_PlayerGroup]: """ - Return (a shallow copy of) the groups this manager manages. + Return (a shallow copy of) the player groups this manager manages. Returns ------- - set of PlayerGroup + Set[_PlayerGroup] Player groups this manager manages. """ return set(self._id_to_group.values()) - def get_group_by_id(self, playergroup_id): + def get_managee_by_id(self, managee_id: str) -> _PlayerGroup: """ - If `playergroup_id` is the ID of a player group managed by this manager, return the group. + If `managee_id` is the ID of a player group managed by this manager, return the player + group. Parameters ---------- - playergroup_id : str + managee_id : str ID of the player group this manager manages. Returns ------- - PlayerGroup - The player group that matches the given tag. + _PlayerGroup + The player group with that ID. Raises ------ PlayerGroupError.ManagerInvalidGroupIDError: - If `playergroup_id` is not the ID of a group this manager manages. + If `managee_id` is not the ID of a player group this manager manages. """ try: - return self._id_to_group[playergroup_id] + return self._id_to_group[managee_id] except KeyError: raise PlayerGroupError.ManagerInvalidGroupIDError - def get_group_limit(self): + def get_managee_by_numerical_id(self, managee_numerical_id: Union[str, int]) -> _PlayerGroup: + """ + If `managee_numerical_id` is the numerical ID of a player group managed by this manager, + return the player group. + + Parameters + ---------- + managee_numerical_id : Union[str, int] + Numerical ID of the player group this manager manages. + + Returns + ------- + _PlayerGroup + The player group with that ID. + + Raises + ------ + PlayerGroupError.ManagerInvalidGroupIDError: + If `managee_numerical_id` is not the numerical ID of a player group + this manager manages. + + """ + + try: + managee_numerical_id = int(managee_numerical_id) + except ValueError: + raise PlayerGroupError.ManagerInvalidGroupIDError + + for group in self._id_to_group.values(): + if group.get_numerical_id() == managee_numerical_id: + return group + raise PlayerGroupError.ManagerInvalidGroupIDError + + def get_managee_limit(self) -> Union[int, None]: """ Return the player group limit of this manager. Returns ------- - int + Union[int, None] Player group limit. """ - return self._playergroup_limit + return self._group_limit - def get_group_ids(self): + def get_managee_ids(self) -> Set[str]: """ Return (a shallow copy of) the IDs of all player groups managed by this manager. Returns ------- - set of str - The IDs of all managed player groups. + Set[str] + The IDs of all player groups this manager manages. """ return set(self._id_to_group.keys()) - def get_groups_of_user(self, user): + def get_managee_ids_to_managees(self) -> Dict[str, _PlayerGroup]: + """ + Return a mapping of the IDs of all player groups managed by this manager to their associated + player group. + + Returns + ------- + Dict[str, _PlayerGroup] + Mapping. + """ + + return self._id_to_group.copy() + + def get_managee_numerical_ids_to_managees(self) -> Dict[int, _PlayerGroup]: + """ + Return a mapping of the numerical IDs of all player groups managed by this manager to their + associated player group. + + Returns + ------- + Dict[int, _PlayerGroup] + Mapping. + """ + + temp = dict() + for group in self._id_to_group.values(): + temp[group.get_numerical_id()] = group + + output = dict() + for num_id in sorted(temp.keys()): + output[num_id] = temp[num_id] + + return output + + def get_managees_of_user(self, user: ClientManager.Client) -> Set[_PlayerGroup]: """ Return (a shallow copy of) the player groups managed by this manager user `user` is a player of. If the user is part of no such player group, an empty set is returned. @@ -1097,31 +1608,68 @@ def get_groups_of_user(self, user): Returns ------- - set of PlayerGroup + Set[_PlayerGroup] Player groups the player belongs to. """ + users_to_managees = self.get_player_to_managees_map() + try: - return self._user_to_groups[user].copy() + return users_to_managees[user].copy() except KeyError: return set() - def get_users_in_groups(self): + def get_player_to_managees_map(self) -> Dict[ClientManager.Client, Set[_PlayerGroup]]: + """ + Return a mapping of the players part of any player group managed by this manager to the + player groups managed by this manager such players belong to. + + Returns + ------- + Dict[ClientManager.Client, Set[_PlayerGroup]] + Mapping. + """ + + output = dict() + for group in self._id_to_group.values(): + for player in group.get_players(): + if player not in output: + output[player] = set() + output[player].add(group) + + return output + + def get_users_in_some_managee(self) -> Set[ClientManager.Client]: """ Return (a shallow copy of) all the users that are part of some player group managed by this manager. Returns ------- - set of ClientManager.Client + Set[ClientManager.Client] Users in some managed player group. """ - return set(self._user_to_groups.keys()) + return set(self.get_player_to_managees_map().keys()) + + def is_managee_creatable(self) -> bool: + """ + Return whether a new player group can currently be created without creating one. + + Returns + ------- + bool + True if a player group can be currently created, False otherwise. + """ + + limit = self.get_managee_limit() + if limit is None: + return True + return len(self._id_to_group) < limit - def get_available_group_id(self): + def get_available_managee_id(self) -> str: """ Get a player group ID that no other player group managed by this manager has. @@ -1133,22 +1681,23 @@ def get_available_group_id(self): Raises ------ PlayerGroupError.ManagerTooManyGroupsError - If the manager is already managing its maximum number of groups. + If the manager is already managing its maximum number of player groups. """ group_number = 0 - while self.get_group_limit() is None or group_number < self.get_group_limit(): - new_group_id = "pg{}".format(group_number) - if new_group_id not in self._id_to_group.keys(): - return new_group_id + limit = self.get_managee_limit() + while limit is None or group_number < limit: + new_managee_id = f'pg{group_number}' + if new_managee_id not in self._id_to_group: + return new_managee_id group_number += 1 raise PlayerGroupError.ManagerTooManyGroupsError - def get_id(self): + def get_id(self) -> str: """ Return the ID of this manager. This ID is guaranteed to be unique among - simultaneously existing managers. + simultaneously existing Python objects. Returns ------- @@ -1157,16 +1706,19 @@ def get_id(self): """ - return hex(id(self)) + return self._id - def find_player_concurrent_limiting_group(self, user): + def find_player_concurrent_limiting_managee( + self, + user: ClientManager.Client + ) -> Union[_PlayerGroup, None]: """ For user `user`, find a player group `most_restrictive_group` managed by this manager such that, if `user` were to join another player group managed by this manager, they would violate `most_restrictive_group`'s concurrent player membership limit. - If no such group exists (or the player is not member of any player group managed by this - manager), return None. - If multiple such groups exist, any one of them may be returned. + If no such player group exists (or the player is not member of any player group managed by + this manager), return None. + If multiple such player groups exist, any one of them may be returned. Parameters ---------- @@ -1175,12 +1727,12 @@ def find_player_concurrent_limiting_group(self, user): Returns ------- - PlayerGroup or None + Union[_PlayerGroup, None] Limiting player group as previously described if it exists, None otherwise. """ - groups = self.get_groups_of_user(user) + groups = self.get_managees_of_user(user) if not groups: return None @@ -1194,56 +1746,14 @@ def find_player_concurrent_limiting_group(self, user): # 1. If the player is member of at least as many groups as this group's limit, this group # is an example group that can be returned. # 2. Otherwise, no other groups exist due to the minimality condition. - most_restrictive_group = min(groups_with_limit, - key=lambda group: group.get_player_concurrent_limit()) + most_restrictive_group: _PlayerGroup = min( + groups_with_limit, + key=lambda group: group.get_player_concurrent_limit() + ) if len(groups) < most_restrictive_group.get_player_concurrent_limit(): return None return most_restrictive_group - def _add_user_to_mapping(self, user, group): - """ - Update the user to player groups mapping with the information that `user` was added to - `group`. - - Parameters - ---------- - user : ClientManager.Client - User that was added. - group : PlayerGroup - Player group that `user` was added to. - - Returns - ------- - None. - - """ - - try: - self._user_to_groups[user].add(group) - except KeyError: - self._user_to_groups[user] = {group} - - def _remove_user_from_mapping(self, user, group): - """ - Update the user to player groups mapping with the information that `user` was removed from - `group`. - - Parameters - ---------- - user : ClientManager.Client - User that was removed. - group : PlayerGroup - Player group that `user` was removed from. - - Returns - ------- - None. - - """ - self._user_to_groups[user].remove(group) - if not self._user_to_groups[user]: - self._user_to_groups.pop(user) - def _check_structure(self): """ Assert that all invariants specified in the class description are maintained. @@ -1256,32 +1766,27 @@ def _check_structure(self): """ # 1. - if self._playergroup_limit is not None: - err = (f'For player group manager {self}, expected that it managed at most ' - f'{self._playergroup_limit} player groups, but found it managed ' - f'{len(self._id_to_group)} player groups. || {self}') - assert len(self._id_to_group) <= self._playergroup_limit, err + if self._group_limit is not None: + assert len(self._id_to_group) <= self._group_limit, ( + f'For player group manager {self._id}, expected that it managed at most ' + f'{self._group_limit} player groups, but found it managed ' + f'{len(self._id_to_group)} player groups. || {self}' + ) # 2. for (playergroup_id, playergroup) in self._id_to_group.items(): # 2a. - err = (f'For player group manager {self}, expected that player group {playergroup} ' - f'that appears in the ID to player group mapping has the same ID as in the ' - f'mapping, but found it did not.') - assert playergroup.get_id() == playergroup_id, err + assert playergroup.get_id() == playergroup_id, ( + f'For player group manager {self._id}, expected that player group {playergroup} ' + f'that appears in the ID to player group mapping has the same ID as in the ' + f'mapping, but found it did not. || {self}' + ) # 2b. - unrecognized = {player for player in playergroup.get_players() - if player not in self._user_to_groups.keys()} - err = (f'For player group manager {self}, expected that the players of player group ' - f'{playergroup} were also recognized as players in the user to group mapping ' - f'of the manager, but found these unrecognized players: {unrecognized}. ') - assert not unrecognized, err - - # 2c. - err = (f'For player group manager {self}, expected that managed player group ' - f'{playergroup} recognized that it was not unmanaged, but found it did.') - assert not playergroup.is_unmanaged(), err + assert not playergroup.is_unmanaged(), ( + f'For player group manager {self._id}, expected that managed player group ' + f'{playergroup} recognized that it was not unmanaged, but found it did.' + ) # 3. for playergroup1 in self._id_to_group.values(): @@ -1290,39 +1795,15 @@ def _check_structure(self): continue # 3a. - err = (f'For player group manager {self}, expected that its two managed groups ' - f'{playergroup1}, {playergroup2} had unique player group IDs, but found ' - f'they did not. || {self}') - assert playergroup1.get_id() != playergroup2.get_id(), err + assert playergroup1.get_id() != playergroup2.get_id(), ( + f'For player group manager {self._id}, expected that its two managed player ' + f'groups {playergroup1}, {playergroup2} had unique player group IDs, but ' + f'found they did not. || {self}' + ) # 4. - for user in self._user_to_groups: - playergroups = self._user_to_groups[user] - - # a. - err = (f'For player group manager {self}, expected that user {user} to only appear ' - f'in the user to player groups mapping if it was a player of any player ' - f'group managed by this manager, but found it appeared while not belonging to ' - f'any player group. || {self}') - assert playergroups, err - - for group in playergroups: - # b. - err = (f'For player group manager {self}, expected that player group {group} ' - f'that appears in the user to player group mapping for user {user} ' - f'also appears in the player group ID to player group mapping, but found ' - f'it did not. || {self}') - assert group in self._id_to_group.values(), err - - # c. - err = (f'For player group manager {self}, expected that user {user} in the user ' - f'to group mapping be a player of its associated group {group}, but ' - f'found that was not the case. || {self}') - assert user in group.get_players(), err - - # 5. - for user in self._user_to_groups: - playergroups = self._user_to_groups[user] + user_to_groups = self.get_player_to_managees_map() + for (user, playergroups) in user_to_groups.items(): membership = len(playergroups) for group in playergroups: @@ -1330,11 +1811,13 @@ def _check_structure(self): if limit is None: continue - err = (f'For player group manager {self}, expected that user {user} in group ' - f'{group} belonged to at most the concurrent player membership limit of ' - f'that group of {limit} group{"s" if limit != 1 else ""}, found they ' - f'belonged to {membership} group{"s" if membership != 1 else ""}. || {self}') - assert membership <= limit + assert membership <= limit, ( + f'For player group manager {self}, expected that user {user} in player ' + f'group {group} belonged to at most the concurrent player membership limit ' + f'of that player group of {limit} player group{"s" if limit != 1 else ""}, ' + f'found they belonged to {membership} player ' + f'group{"s" if membership != 1 else ""}. || {self}' + ) # Last. for playergroup in self._id_to_group.values(): @@ -1351,9 +1834,15 @@ def __repr__(self): """ - return (f"PlayerGroupManager(server, playergroup_limit={self._playergroup_limit}, " - f"default_playergroup_type={self._default_playergroup_type}, " + return (f"PlayerGroupManager(server, managee_limit={self.get_managee_limit()}, " + f"default_managee_type={self.get_managee_type()}, " f"|| " - f"_user_to_groups={self._user_to_groups}, " - f"_id_to_group={self._id_to_group}, " - f"id={hex(id(self))})") + f"_user_to_managees={self.get_player_to_managees_map()}, " + f"_id_to_managee={self.get_managee_ids_to_managees()}, " + f"id={self.get_id()}, " + f')') + +if __name__ == '__main__': + import sys + if r'..' not in sys.path: + sys.path.append(r'..') diff --git a/server/steptimer_manager.py b/server/steptimer_manager.py deleted file mode 100644 index 4e49ada2e..000000000 --- a/server/steptimer_manager.py +++ /dev/null @@ -1,1140 +0,0 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server -# -# Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee -# -# 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 3 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, see . - -""" -Module that contains the SteptimerManager class, which itself contains the Steptimer class. - -A steptimer is a timer with an apparent timer value that ticks up/down a fixed length of time (the -timestep length) once every fixed interval (the firing interval). This allows timers that simulate -slow downs or fast forwarding (for example, a timer that ticks down one second once every real -5 seconds). - -A steptimer ticks up in one step if at that step the timestep length is positive. A steptimer ticks -down in one step instead if at that step the timestep length is negative. - -A steptimer when initialized does not start automatically, but once it starts, it will tick up/down -as described previously. - -A steptimer can be paused, so that the apparent timer will stop changing. It can also be unpaused, -so the apparent timer will start changing again by the previously described interval rules. -the interval rules). The length of the first step after unpausing is set to be the current fixed -interval length minus the elapsed time in that step. - -Once the apparent timer ticks down below some specified minimum or ticks up above some specified -maximum, it will end automatically and be deleted. If the apparent timer is updated above the -specified maximum but the timer is set to tick down, the timer will not end but will be set to the -maximum (a similar behavior occurs for updating to below the specified minimum for a tick up timer). -A steptimer can also be terminated before it automatically ends. - -A steptimer allows the implementation different callback functions that will be executed on the -following events: -* When the steptimer ends automatically by ticking DOWN to or below some specified minimum timer - value. -* When the steptimer ends automatically by ticking UP to or above some specified maximum timer - value. -* When the steptimer ticks up/down once the firing interval elapses, but is not due to end - automatically (see previous two points) -""" - -import asyncio -import time - -from server.constants import Constants -from server.exceptions import SteptimerError -from server.logger import log_print - -class SteptimerManager: - """ - A mutable data type for a manager for steptimers in a server. - - Contains the steptimer object definition, as well as a mapping of steptimer IDs to their - associated steptimers. - - The class (with its default implementation) is coroutine-safe as the default steptimer class - is coroutine-safe and every other public method of the manager is synchronous. - - The class is NOT thread-safe. - - Overwritable Methods - -------------------- - get_available_steptimer_id : - Generate a new steptimer ID. Can be modified to provide different steptimer ID formats. - - """ - - # (Private) Attributes - # -------------------- - # _server : TsuserverDR - # Server the steptimer manager belongs to. - # _id_to_steptimer : dict of str to self.Steptimer - # Mapping of steptiomer IDs to steptimers that this manager manages. - # _steptimer_limit : int or None - # If an int, it is the maximum number of steptimers this manager supports. If None, the - # manager may manage an arbitrary number of steptimers. - - # Invariants - # ---------- - # 1. If `self._steptimer_limit` is an int, then `len(self._id_to_steptimer) <=` - # `self._steptimer_limit`. - # 2. For every tuple `(steptimer_id, steptimer)` in `self._id_to_group.items()`: - # a. `steptimer._manager = self`. - # b. `steptimer._steptimer_id = steptimer_id`. - # 3. For every pair of distinct steptimers `steptimer1` and `steptimer2` in - # `self._id_to_steptimer.values()`: - # a. `steptimer1._steptimer_id != steptimer1._steptimer_id`. - - class Steptimer: - """ - A mutable data type representing steptimers. - - A steptimer is a timer with an apparent timer value that ticks up/down a fixed period of - time once every fixed interval (a step). - - The class is coroutine-safe as the only points where public method execution yields to a - coroutine is after all writes were performed and the only missing step is structural - integrity tests, which only involve reads. - - However, the class itself is NOT thread-safe, as it only uses asyncio methods that - themselves are not thread-safe. - - Overwritable Methods - -------------------- - _on_timestep_end : - Callback function to be executed every time a timestep ends normally. - _on_min_end : - Callback function to be executed if the steptimer's apparent timer ticks down to or - below its minimum timer value. - _on_max_end : - Callback function to be executed if the steptimer's apparent timer ticks up to or - above its maximum timer value. - - Class Attributes - ---------------- - FRAME_LENGTH : float - Length of one frame in seconds assuming 60 FPS. - DEF_TIMESTEP_LENGTH : float - Default timestep length of all steptimers of this type. - DEF_START_TIMER_VALUE : float - Default start value the apparent timer of all steptimers of this type. - DEF_MIN_TIMER_VALUE : float - Default minimum value the apparent timer of all steptimers of this type. - DEF_MAX_TIMER_VALUE : float - Default maximum value the apparent timer of all steptimers of this type. - - """ - - # (Private) Attributes - # -------------------- - # _server : TsuserverDR - # Server the steptimer belongs to. - # _manager : SteptimerManager - # Manager for this steptimer. - # _steptimer_id : str - # Identifier for this steptimer. - # _timer_value : float - # Number of seconds in the apparent steptimer. - # _timestep_length : float - # Number of seconds that tick from the apparent timer every step. - # _firing_interval : float - # Number of seconds that must elapse for the apparent timer to tick. It must be a - # positive number. - # _min_timer_value : float - # Minimum value the apparent timer may take. If the timer ticks below this, it will end - # automatically. - # _max_timer_value : float - # Maximum value the apparent timer may take. If the timer ticks above this, it will end - # automatically. - # _was_started : bool - # True if the apparent timer was ever started, False otherwise. - # _was_terminated : bool - # True if the steptimer was terminated, False otherwise. - # _is_paused : bool - # True if the apparent timer is paused, False otherwise. - # _just_paused : bool - # True briefly after the apparent timer is paused, False otherwise. - # _just_unpaused : bool - # True briefly after the apparent timer is unpaused, False otherwise. - # _just_refreshed : bool - # True briefly after the apparent timer is refreshed, False otherwise. - # _last_timestep_update : float - # Time (as per time.time()) when the apparent timer last ticked or was last refreshed, - # whichever happened later. - # _time_spent_in_timestep : float - # Time in seconds the steptimer's current timestep has run, ignoring time paused. - # _task : asyncio.Task or None - # Actual timer task. _task is None as long as the steptimer is not started. - - # Invariants - # ---------- - # 1. `0 <= self._min_timer_value <= self._timer_value <= self._max_timer_value` - # 2. `self._firing_interval > 0` - # 3. `self._timestep_length != 0` - # 4. `0 <= self.DEF_MIN_TIMER_VALUE <= self.DEF_START_TIMER_VALUE - # <= self.DEF_MAX_TIMER_VALUE` - # 5. `self.DEF_TIMESTEP_LENGTH != 0` - - FRAME_LENGTH = 1/60 # Length of one frame in seconds assuming 60 FPS - DEF_TIMESTEP_LENGTH = FRAME_LENGTH - DEF_START_TIMER_VALUE = 0 - DEF_MIN_TIMER_VALUE = 0 - DEF_MAX_TIMER_VALUE = 60*60*6 # 6 hours in seconds - - def __init__(self, server, manager, steptimer_id, start_timer_value=None, - timestep_length=None, firing_interval=None, - min_timer_value=None, max_timer_value=None): - """ - Create a new steptimer. - - Parameters - ---------- - server : TsuserverDR - Server the steptimer belongs to. - manager : SteptimerManager - Manager for this steptimer. - steptimer_id : str - Identifier for this steptimer. - start_timer_value : float, optional - Number of seconds in the apparent steptimer when it is created. Defaults to None - (and then set to self.DEF_START_TIMER_VALUE) - timestep_length : float, optional - Number of seconds that tick from the apparent timer every step. Must be a non- - negative number at least `min_timer_value` and `max_timer_value`. Defaults to None, - (and then set to self.DEF_TIMESTEP_LENGTH). - firing_interval : float, optional - Number of seconds that must elapse for the apparent timer to tick. Defaults to - None (and then set to abs(timestep_length)). - min_timer_value : float, optional - Minimum value the apparent timer may take. If the apparent timer ticks below this, - it will end automatically. It must be a non-negative number. Defaults to None - (and then set to self.DEF_MIN_TIMER_VALUE). - max_timer_value : float, optional - Maximum value the apparent timer may take. If the apparent timer ticks above this, - it will end automatically. Defaults to None (and then set to - self.DEF_MAX_TIMER_VALUE). - - Returns - ------- - None. - - Raises - ------ - SteptimerError.TimerTooLowError - If `timer_value < min_timer_value` - SteptimerError.TimerTooHighError - If `timer_value > max_timer_value` - SteptimerError.InvalidMinTimerValueError - If `min_timer_value < 0` - SteptimerError.InvalidFiringIntervalError - If `firing_interval <= 0` - SteptimerError.InvalidTimestepLengthError - If `timestep_length == 0` - - """ - - if start_timer_value is None: - start_timer_value = self.DEF_START_TIMER_VALUE - if min_timer_value is None: - min_timer_value = self.DEF_MIN_TIMER_VALUE - if max_timer_value is None: - max_timer_value = self.DEF_MAX_TIMER_VALUE - if timestep_length is None: - timestep_length = self.DEF_TIMESTEP_LENGTH - if firing_interval is None: - firing_interval = abs(timestep_length) - - if start_timer_value < min_timer_value: - raise SteptimerError.TimerTooLowError - if start_timer_value > max_timer_value: - raise SteptimerError.TimerTooHighError - if min_timer_value < 0: - raise SteptimerError.InvalidMinTimerValueError - if firing_interval <= 0: - raise SteptimerError.InvalidFiringIntervalError - if timestep_length == 0: - raise SteptimerError.InvalidTimestepLengthError - - self._server = server - self._manager = manager - self._steptimer_id = steptimer_id - self._timer_value = start_timer_value - - self._timestep_length = float(timestep_length) - self._firing_interval = float(firing_interval) - self._min_timer_value = float(min_timer_value) - self._max_timer_value = float(max_timer_value) - - self._was_started = False - self._was_terminated = False - self._is_paused = False - self._just_paused = False - self._just_unpaused = False - self._due_continue_timestep_progress = False - self._last_timestep_update = time.time() - self._time_spent_in_timestep = 0 - - # This task will be an _as_timer coroutine that is meant to emulate the apparent timer. - # This task will frequently be canceled for the purposes of updating the timer on user - # request, or for the purposes of pausing or refreshed, and thus terminations will be - # suppresed. The only way asyncio.CancelledError will not be ignored is via the - # apparent timer ticking up beyond its maximum or down beyond its minimum, or via - # .terminate_timer() - self._task = None - self._check_structure() - - def get_id(self): - """ - Return the ID of this steptimer. - - Returns - ------- - str - The ID of this steptimer. - - """ - - return self._steptimer_id - - def was_started(self): - """ - Return True if the apparent timer has been started already, False otherwise. - - Once a steptimer was started, this always returns True, even if it is eventually paused - or terminated. - - Returns - ------- - bool - True if it was ever started, False otherwise. - """ - - return self._was_started - - def start_timer(self): - """ - Start the steptimer. Requires the timer was not started and is not terminated. - - Returns - ------- - None. - - Raises - ------ - SteptimerError.AlreadyTerminatedSteptimerError: - If the steptimer has already been terminated. - SteptimerError.AlreadyStartedSteptimerError: - If the steptimer has not been terminated yet but already has started. - - """ - - if self._was_terminated: - raise SteptimerError.AlreadyTerminatedSteptimerError - if self._was_started: - raise SteptimerError.AlreadyStartedSteptimerError - - self._was_started = True - self._is_paused = False - self._just_paused = False - self._due_continue_timestep_progress = False - - self._continue_timestep() - self._check_structure() - - def was_terminated(self): - """ - Return True if the apparent timer has been terminated already, False otherwise. - - A steptimer can be terminated by either manually terminating it via the .terminate_timer - method, or by it min-ending or max-ending. - - Returns - ------- - bool - True if it was terminated, False otherwise. - """ - - return self._was_terminated - - def terminate_timer(self): - """ - Terminate the steptimer. Requires the timer not be terminated already. - - Returns - ------- - None. - - Raises - ------- - SteptimerError.AlreadyTerminatedSteptimerError: - If the steptimer was already terminated. - - """ - - if self._was_terminated: - raise SteptimerError.AlreadyTerminatedSteptimerError - - self._was_terminated = True - self._refresh() - self._check_structure() - - def is_paused(self): - """ - Return True if the apparent timer is paused, False otherwise. - - This returns False if the timer was not started! - - Returns - ------- - bool - True if paused, False otherwise. - - """ - - return self._is_paused - - def pause_timer(self): - """ - Pause the steptimer. Requires the timer was started, is not terminated nor paused. - - Returns - ------- - None. - - Raises - ------ - SteptimerError.AlreadyTerminatedSteptimerError: - If the steptimer has been already terminated. - SteptimerError.NotStartedSteptimerError: - If the steptimer has not been terminated nor started yet. - SteptimerError.AlreadyPausedSteptimerError: - If the steptimer has not been terminated, has been started and is currently paused. - - """ - - if self._was_terminated: - raise SteptimerError.AlreadyTerminatedSteptimerError - if not self._was_started: - raise SteptimerError.NotStartedSteptimerError - if self._is_paused: - raise SteptimerError.AlreadyPausedSteptimerError - - self._is_paused = True - self._just_paused = True - self._update_subtimestep_elapsed() - self._refresh() - self._check_structure() - - def unpause_timer(self): - """ - Unpause the steptimer. Requires the timer was started, is not terminated and is paused. - - Returns - ------- - None. - - Raises - ------ - SteptimerError.AlreadyTerminatedSteptimerError: - If the steptimer has been already terminated. - SteptimerError.NotStartedSteptimerError: - If the steptimer has not been terminated nor started yet. - SteptimerError.NotPausedSteptimerError: - If the steptimer has not been terminated, has been started and is currently not - paused. - - """ - - if not self._was_started: - raise SteptimerError.NotStartedSteptimerError - if self._was_terminated: - raise SteptimerError.AlreadyTerminatedSteptimerError - if not self._is_paused: - raise SteptimerError.NotPausedSteptimerError - - self._is_paused = False - self._due_continue_timestep_progress = True - self._just_unpaused = True - self._update_subtimestep_elapsed() - self._continue_timestep() - self._check_structure() - - def get_time(self): - """ - Get current apparent timer (in seconds). - - Returns - ------- - float - Current apparent timer. - - """ - - return self._timer_value - - def get_firing_interval(self): - """ - Get current firing interval (in seconds). - - Returns - ------- - float - Current firing interval. - - """ - - return self._firing_interval - - def get_timestep_length(self): - """ - Get current timestep length (in seconds). - - Returns - ------- - float - Current timestep length. - - """ - - return self._timestep_length - - def set_time(self, new_time): - """ - Set the apparent timer of the steptimer to `new_time`. This will also interrupt the - current running timestep (if there is one) without calling the steptimer's firing - callback function and start a new one. - If `new_time` is less than the steptimer's minimum timer value, the timer will be - adapted to take this minimum value instead. Similarly, if `new_time` is more than the - steptimer's maximum timer value, the timer will be adapted to take this maximum value - instead. - - Parameters - ---------- - new_time : float - New apparent timer of the steptimer. - - Returns - ------- - None. - - """ - - self._timer_value = float(new_time) - if self._timer_value < self._min_timer_value: - self._timer_value = self._min_timer_value - elif self._timer_value > self._max_timer_value: - self._timer_value = self._max_timer_value - - self._refresh() - self._continue_timestep() - self._check_structure() - - def set_firing_interval(self, new_interval): - """ - Update the firing interval of the steptimer for all future timesteps. If a timestep - has started when this function is scheduled to run, the current timestep will be - readapted to finish in this new firing interval as follows: - * Assume length x of the 'current' timestep with orig has elapsed. - * Then, the current timestep will be adapted to have length max(0, new_interval-x) - - Parameters - ---------- - new_interval : float - New firing interval. It must be a positive number. - - Raises - ------ - SteptimerError.InvalidFiringIntervalError - If `new_interval <= 0`. - - Returns - ------- - None. - - """ - - if new_interval <= 0: - raise SteptimerError.InvalidFiringIntervalError - - self._update_subtimestep_elapsed() # Update before updating new firing interval - self._firing_interval = float(new_interval) - self._refresh() - self._continue_timestep() - self._check_structure() - - def set_timestep_length(self, new_length): - """ - Update the timestep length of the steptimer for all future timesteps and the current - one if one has started by the time this function is scheduled to run. - - Parameters - ---------- - new_length : float - New timestep length. It must not be zero. - - Raises - ------ - SteptimerError.InvalidTimestepLengthError - If `new_length == 0`. - - Returns - ------- - None. - - """ - - if new_length == 0: - raise SteptimerError.InvalidTimestepLengthError - - self._update_subtimestep_elapsed() # Update before updating new timestep length - self._timestep_length = float(new_length) - self._refresh() - self._continue_timestep() - self._check_structure() - - def change_time_by(self, time_difference): - """ - Change the apparent timer of the steptimer by `time_difference`. This will also - nterrupt the current running timestep (if there is one) without calling the - steptimer's firing callback function and start a new one. - If the new apparent timer time is less than the steptimer's minimum timer value, the - timer will be adapted to take this minimum value instead. Similarly, if the new - apparent timer time is more than steptimer's maximum timer value, the timer will be - adapted to take this maximum value instead. - - Parameters - ---------- - time_difference : float - Amount of time to change the apparent timer by (possibly zero or negative). - - Returns - ------- - None. - - """ - - self._last_timestep_update = time.time() - self._time_spent_in_timestep = 0 - - self._timer_value += float(time_difference) - if self._timer_value < self._min_timer_value: - self._timer_value = self._min_timer_value - elif self._timer_value > self._max_timer_value: - self._timer_value = self._max_timer_value - - self._refresh() - self._continue_timestep() - self._check_structure() - - def _continue_timestep(self): - if self._was_terminated: - # This code should only run if it takes longer for the timer to be terminated than - # the firing interval. - return - - if self._timer_value <= self._min_timer_value: - self._timer_value = self._min_timer_value - if self._timestep_length < 0: - self._was_terminated = True - self._on_min_end() - return - elif self._timer_value >= self._max_timer_value: - self._timer_value = self._max_timer_value - if self._timestep_length > 0: - self._was_terminated = True - self._on_max_end() - return - - # This moment represents the instant a timestep resumes from pausing/refreshing - # or the very beginning of one. - self._just_paused = False - self._just_unpaused = False - - # If the timer is paused, wait _firing_interval seconds again - if self._is_paused: - return - - # Else, if a timestep just finished without any interruptions - # Reset time spent in timestep and last update to timestep - if not self._due_continue_timestep_progress: - self._reset_subtimestep_elapsed() - self._on_timestep_end() - - adapted_interval = self._firing_interval-self._time_spent_in_timestep - if adapted_interval < 0: - adapted_interval = 0 - - self._due_continue_timestep_progress = False - self._task = Constants.create_fragile_task(self._wait_timestep_end(adapted_interval)) - - async def _wait_timestep_end(self, time_to_end): - start_time = time.perf_counter() - async def _wait(): - while time.perf_counter() < start_time+time_to_end: - await asyncio.sleep(0) - await _wait() - # await asyncio.sleep(time_to_end) - # b = time.perf_counter() - # print(b-a) - self._timer_value += self._timestep_length - self._continue_timestep() - - def _refresh(self): - """ - Interrupt the current timestep with a cancellation order. - If the steptimer is not currently running, this function does nothing. - - Returns - ------- - None. - - """ - - if not self._task: - return - - self._due_continue_timestep_progress = True - Constants.create_fragile_task(self._cancel_and_await(self._task)) - - def _reset_subtimestep_elapsed(self): - """ - Reset the timestep's sub indicators so as to indicate the start of a new timestep. - - Returns - ------- - None. - - """ - - self._last_timestep_update = time.time() - self._time_spent_in_timestep = 0 - - def _update_subtimestep_elapsed(self): - """ - Update the timestep's sub indicators with the time spent since the last update. - - Returns - ------- - None. - - """ - - time_now = time.time() - # If the timer is paused but was not just paused, this is just - # self._time_spent_in_timestep - if (self._is_paused and not self._just_paused) or self._just_unpaused: - newest_last_timestep_update = self._last_timestep_update - else: - newest_last_timestep_update = time_now - # Otherwise, this is constantly changing - # so find real time elapsed since last update to subtimestep_elapsed - self._time_spent_in_timestep += newest_last_timestep_update - self._last_timestep_update - self._last_timestep_update = time_now - - async def _no_action(self): - """ - Dummy function that does nothing, but allows other coroutines to start running. - - Returns - ------- - None. - - """ - - await asyncio.sleep(0) - - async def _cancel_and_await(self, old_task): - """ - Async function that cancels `old_task` and awaits until it is able to properly retrieve - the cancellation exception from `old_task`. This function assumes the task has not been - cancelled yet. - - Parameters - ---------- - old_task : asyncio.Task - Task to cancel - - Returns - ------- - None. - - """ - - try: - old_task.cancel() - await old_task - except asyncio.CancelledError: - pass - - def _on_timestep_end(self): - """ - Callback function that is executed every time the steptimer is updated due to its - firing interval elapsing. - - Returns - ------- - None. - - """ - - # log_print('Timer {} ticked to {}'.format(self._steptimer_id, self._timer_value)) - - def _on_min_end(self): - """ - Callback function that is executed once: once the steptimer time ticks DOWN to at most - the steptimer minimum timer value. - - Returns - ------- - None. - - """ - - # log_print('Timer {} min-ended at {}'.format(self._steptimer_id, self._timer_value)) - - def _on_max_end(self): - """ - Callback function that is executed once: once the steptimer time ticks UP to at least - the steptimer maximum timer value. - - Returns - ------- - None. - - """ - - # log_print('Timer {} max-ended at {}'.format(self._steptimer_id, self._timer_value)) - - def _check_structure(self): - """ - Assert that all invariants in the class description are satisfied. - - Returns - ------- - None. - - Raises - ------ - AssertionError: - If any of the invariants are not satisfied. - """ - - # 1. - err = (f'Expected the steptimer minimum timer value be a non-negative number, found ' - f'it was {self._min_timer_value} instead.') - assert self._min_timer_value >= 0, err - - err = (f'Expected the steptimer timer value be at least the minimum timer value ' - f'{self._min_timer_value}, found it was {self._timer_value} instead.') - assert self._timer_value >= self._min_timer_value, err - - err = (f'Expected the steptimer timer value be at most the maximum timer value ' - f'{self._max_timer_value}, found it was {self._timer_value} instead.') - assert self._timer_value <= self._max_timer_value, err - - # 2. - err = (f'Expected the firing interval be a positive number, found it was ' - f'{self._firing_interval} instead.') - assert self._firing_interval > 0, err - - # 3. - err = 'Expected the timestep length be non-zero, found it was zero.' - assert self._timestep_length != 0, err - - # 4. - err = (f'Expected the default steptimer minimum timer value be a non-negative number, ' - f'found it was {self.DEF_MIN_TIMER_VALUE} instead.') - assert self.DEF_MIN_TIMER_VALUE >= 0, err - - err = (f'Expected the default steptimer timer value be at least the default minimum ' - f'timer value {self.DEF_MIN_TIMER_VALUE}, found it was ' - f'{self.DEF_START_TIMER_VALUE}.') - assert self.DEF_START_TIMER_VALUE >= self.DEF_MIN_TIMER_VALUE, err - - err = (f'Expected the default steptimer timer value be at most the default maximum ' - f'timer value {self.DEF_MAX_TIMER_VALUE}, found it was ' - f'{self.DEF_START_TIMER_VALUE} instead.') - assert self.DEF_START_TIMER_VALUE <= self.DEF_MAX_TIMER_VALUE, err - - # 5. - err = 'Expected the default timestep length be non-zero, found it was zero.' - assert self.DEF_TIMESTEP_LENGTH != 0, err - - def __init__(self, server, steptimer_limit=None): - """ - Create a steptimer manager object. - - Parameters - ---------- - server : TsuserverDR - The server this steptimer manager belongs to. - steptimer_limit : int, optional - The maximum number of steptimers this manager can handle. The default is None. - - """ - - self._server = server - self._steptimer_limit = steptimer_limit - - self._id_to_steptimer = dict() - - def new_steptimer(self, steptimer_type=None, start_timer_value=None, timestep_length=None, - firing_interval=None, min_timer_value=None, max_timer_value=None): - """ - Create a new steptimer with given parameters managed by this manager. - - Parameters - ---------- - steptimer_type : SteptimerManager.Steptimer, optional - Class of steptimer that will be produced. Defaults to None (and converted to - self.Steptimer) - start_timer_value : float, optional - Number of seconds the apparent timer the steptimer will initially have. Defaults to - None (will use the default from `steptimer_type`). - timestep_length : float, optional - Number of seconds that tick from the apparent timer every step. Must be a non- - negative number at least `min_timer_value` and `max_timer_value`. Defaults to - None (will use the default from `steptimer_type`). - firing_interval : float, optional - Number of seconds that must elapse for the apparent timer to tick. Defaults to None - (and converted to abs(timestep_length)) - min_timer_value : float, optional - Minimum value the apparent timer may take. If the timer ticks below this, it will end - automatically. It must be a non-negative number. Defaults to None (will use the - default from `steptimer_type`.) - max_timer_value : float, optional - Maximum value the apparent timer may take. If the timer ticks above this, it will end - automatically. Defaults to None (will use the default from `steptimer_type`). - - Returns - ------- - self.Steptimer - The created steptimer. - - Raises - ------ - SteptimerError.ManagerTooManySteptimersError - If the manager is already managing its maximum number of steptimers. - - """ - - # Check if adding a new steptimer to manage would be one too many - if self._steptimer_limit is not None: - if len(self._id_to_steptimer) >= self._steptimer_limit: - raise SteptimerError.ManagerTooManySteptimersError - - # Fill in default values - if steptimer_type is None: - steptimer_type = self.Steptimer - if firing_interval is None and timestep_length is not None: - firing_interval = abs(timestep_length) - - # Generate a steptimer ID and the new steptimer - steptimer_id = self.get_available_steptimer_id() - steptimer = steptimer_type(self._server, self, steptimer_id, - start_timer_value=start_timer_value, - timestep_length=timestep_length, - firing_interval=firing_interval, - min_timer_value=min_timer_value, - max_timer_value=max_timer_value) - self._id_to_steptimer[steptimer_id] = steptimer - - self._check_structure() - return steptimer - - def delete_steptimer(self, steptimer): - """ - Delete a steptimer managed by this manager, terminating it first if needed. - - Parameters - ---------- - steptimer : SteptimerManager.Steptimer - The steptimer to delete. - - Returns - ------- - str - The ID of the steptimer that was deleted. - - Raises - ------ - SteptimerError.ManagerDoesNotManageSteptimerError - If the manager does not manage the target steptimer. - - """ - - # Assert steptimer is managed by manager. - if not self.manages_steptimer(steptimer): - raise SteptimerError.ManagerDoesNotManageSteptimerError - - steptimer_id = steptimer.get_id() - # Pop the steptimer. By doing this now, it helps guard the class' only call to an - # asynchronous function. In particular, once .delete_steptimer() is called on a managed - # steptimer, these two lines will always execute, which will prevent the steptimer to - # terminate from influencing other public method calls of the manager. - self._id_to_steptimer.pop(steptimer_id) - - try: - steptimer.terminate_timer() - except SteptimerError.AlreadyTerminatedSteptimerError: - pass - - # As the steptimer is popped, it will no longer be referred to in the internal structure - # check function. - self._check_structure() - return steptimer_id - - def manages_steptimer(self, steptimer): - """ - Return True if the steptimer is managed by this manager, False otherwise. - - Parameters - ---------- - steptimer : SteptimerManager.StepTimer - The steptimer to check. - - Returns - ------- - bool - True if the manager manages this steptimer, False otherwise. - - """ - - return steptimer in self._id_to_steptimer.values() - - def get_steptimers(self): - """ - Return (a shallow copy of) the steptimers this manager manages. - - Returns - ------- - set of SteptimerManager.Steptimer - Steptimers this manager manages. - - """ - - return set(self._id_to_steptimer.values()) - - def get_steptimer_by_id(self, steptimer_id): - """ - If `steptimer_tag` is the ID of a steptimer managed by this manager, return that steptimer. - - Parameters - ---------- - steptimer_id: str - ID of steptimer this manager manages. - - Returns - ------- - SteptimerManager.Steptimer - The steptimer whose ID matches the given ID. - - Raises - ------ - SteptimerError.ManagerInvalidSteptimerIDError: - If `steptimer_id` is not the ID of a steptimer this manager manages. - - """ - - try: - return self._id_to_steptimer[steptimer_id] - except KeyError: - raise SteptimerError.ManagerInvalidSteptimerIDError - - def get_steptimer_ids(self): - """ - Return (a shallow copy of) the IDs of all steptimers managed by this manager. - - Returns - ------- - set of str - The IDs of all managed steptimers. - - """ - - return set(self._id_to_steptimer.keys()) - - def get_available_steptimer_id(self): - """ - Get a steptimer ID that no other steptimer managed by this manager has. - - Returns - ------- - str - A unique steptimer ID. - - Raises - ------ - SteptimerError.ManagerTooManySteptimersError - If the manager is already managing its maximum number of steptimers. - - """ - - steptimer_number = 0 - while self._steptimer_limit is None or steptimer_number < self._steptimer_limit: - new_steptimer_id = "st{}".format(steptimer_number) - if new_steptimer_id not in self._id_to_steptimer.keys(): - return new_steptimer_id - steptimer_number += 1 - raise SteptimerError.ManagerTooManySteptimersError - - def _check_structure(self): - """ - Assert that all invariants specified in the class description are maintained. - - Raises - ------ - AssertionError - If any of the invariants are not maintained. - - """ - - # 1. - if self._steptimer_limit is not None: - err = (f'For steptimer manager {self}, expected that it managed at most ' - f'{self._steptimer_limit} steptimers, but found it managed ' - f'{len(self._id_to_steptimer)} steptimers. || {self}') - assert len(self._id_to_steptimer) <= self._steptimer_limit, err - - # 2. - for (steptimer_id, steptimer) in self._id_to_steptimer.items(): - # 2a. - err = (f'For steptimer manager {self}, expected that its managed steptimer ' - f'{steptimer} recognized that it was managed by it, but found it did not. ' - f'|| {self}') - assert steptimer._manager == self, err - - # 2b. - err = (f'For steptimer manager {self}, expected that steptimer {steptimer} ' - f'that appears in the ID to steptimer mapping has the same ID as in the ' - f'mapping, but found it did not. || {self}') - assert steptimer.get_id() == steptimer_id, err - - # 3. - for steptimer1 in self._id_to_steptimer.values(): - for steptimer2 in self._id_to_steptimer.values(): - if steptimer1 == steptimer2: - continue - - # 3a. - err = (f'For steptimer manager {self}, expected that its two managed steptimers ' - f'{steptimer1}, {steptimer2} had unique steptimer IDS, but found ' - f'they did not. || {self}') - assert steptimer1.get_id() != steptimer2.get_id(), err - - # Last. - for steptimer in self._id_to_steptimer.values(): - steptimer._check_structure() diff --git a/server/subscriber.py b/server/subscriber.py index f671182d2..232ed4594 100644 --- a/server/subscriber.py +++ b/server/subscriber.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -20,6 +21,10 @@ Module that implements a simple publisher-listener model. """ +from __future__ import annotations + +from typing import List + class Listener: """ @@ -137,7 +142,7 @@ def unsubscribe(self, unsubscribing_from): publisher = self._get_publisher(unsubscribing_from) publisher.discard(self._parent) - def get_subscriptions(self): + def get_subscriptions(self) -> List[Publisher]: """ Return (a shallow copy of) the publishers this listener is subscribed to. diff --git a/server/tasker.py b/server/task_manager.py similarity index 63% rename from server/tasker.py rename to server/task_manager.py index 7fe514dee..356d0a278 100644 --- a/server/tasker.py +++ b/server/task_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -16,8 +17,9 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# WARNING! -# This class will be reworked for 4.3 +""" +Module that contains the Task class and the TaskManager class. +""" from __future__ import annotations @@ -25,187 +27,245 @@ import time import typing -from typing import Any, List, Tuple +from typing import Any, Callable, Coroutine, Dict, Hashable, Tuple -from server.constants import Constants -from server.exceptions import ServerError +from server.constants import Constants, Effects +from server.exceptions import TaskError, ServerError if typing.TYPE_CHECKING: from server.client_manager import ClientManager from server.tsuserver import TsuserverDR -class Tasker: +class Task: + """ + A task is a wrapper around a coroutine that also stores an owner, name, creation time and + user modifiable parameters. + """ + + def __init__( + self, + async_function: Callable[[Task], Coroutine], + owner: Hashable, + name: str, + creation_time: float, + parameters: Dict[str, Any] + ): + """ + Create a task + + Parameters + ---------- + async_function : Callable[[Task], Coroutine] + Async function that describes what the task will do. This async function will be + scheduled for execution with `self` as its argument. + owner : Hashable + Entity that created the task. + name : str + Name of the task. + creation_time : float + Creation time of the task. Recommended to use time.time(). + parameters : Dict[str, Any] + User parameters to hold for the task. + """ + + async_future = Constants.create_fragile_task(async_function(self)) + self.asyncio_task = async_future + self.owner = owner + self.name = name + self.creation_time = creation_time + self.parameters = parameters.copy() + +class TaskManager: + """ + A task manager is a manager for tasks. + + Tasks should only be created with a task manager. + """ + def __init__(self, server: TsuserverDR): """ Parameters ---------- - server: tsuserver.TsuserverDR - Server of the tasker. + server: TsuserverDR + Server of the task manager. """ self.server = server - self.client_tasks = dict() - self.active_timers = dict() - - def create_task(self, client: ClientManager.Client, args: List): + self.tasks: Dict[Hashable, Dict[str, Task]] = dict() + self.active_timers: Dict[str, ClientManager.Client] = dict() + + def new_task( + self, + owner: Hashable, + name: str, + parameters: Dict[str, Any] = None + ) -> Task: """ - Create a new task for given client with given arguments. + Create a new task with a particular name and owner. If a task linked to that owner and name + already exists, it will be scheduled for cancellation and replaced with this new task. Parameters ---------- - client: ClientManager.Client - Client associated to the task. - args: list - Arguments of the task. + owner : Hashable + Entity that created the task. + name : str + Name of the task. This should be the name of an async function defined within the class. + parameters : Dict[str, Any], optional + Initial user parameters of the task, by default None (and converted to an empty + dictionary). + + Returns + ------- + Task + Created task. """ - # Abort old task if it exists + if parameters is None: + parameters = dict() + try: - old_task = self.get_task(client, args) - if not old_task.done() and not old_task.cancelled(): - self.cancel_task(old_task) - except KeyError: + old_task = self.get_task(owner, name) + except TaskError.TaskNotFoundError: pass + else: + if not old_task.asyncio_task.done() and not old_task.asyncio_task.cancelled(): + self.force_asyncio_cancelled_error(old_task) + + async_function = getattr(self, name) + creation_time = time.time() + + if owner not in self.tasks: + self.tasks[owner] = dict() - async_function = getattr(self, args[0])(client, args[1:]) - async_future = Constants.create_fragile_task(async_function) - self.client_tasks[client.id][args[0]] = (async_future, args[1:], dict()) + self.tasks[owner][name] = Task(async_function, owner, name, creation_time, parameters) + return self.tasks[owner][name] - def cancel_task(self, task: asyncio.Task): + def force_asyncio_cancelled_error( + self, + task: Task + ): """ - Cancel current task and send order to await cancellation. + Force the task to raise an asyncio.CancelledError. This is useful to change execution flow + when a task is internally sleeping. Parameters ---------- - task: asyncio.Task - Task to cancel. + task : Task + Task that will be forced to raise an asyncio.CancelledError. """ - task.cancel() + task.asyncio_task.cancel() # TODO: For some odd reason, it complains if I set it to create_task. Figure that out. - asyncio.ensure_future(self.await_cancellation(task)) + asyncio.ensure_future(Constants.await_cancellation(task.asyncio_task)) - def remove_task(self, client: ClientManager.Client, args: List): + def delete_task( + self, + owner: Hashable, + name: str, + ): """ - Given client and task name, remove task from server.Tasker.client_tasks and cancel it. + Attempt to delete a task linked to an owner and name. Parameters ---------- - client: ClientManager.Client - Client associated to the task. - args: list - Arguments of the task. The first one must be the task name. + owner : Hashable + Owner of the task. + name : str + Name of the task. + + Raises + ------ + TaskError.TaskNotFoundError + If no such task exists. """ - task = self.client_tasks[client.id].pop(args[0]) - self.cancel_task(task[0]) + task = self.get_task(owner, name) + owner_tasks = self.tasks[owner] + owner_tasks.pop(name) + self.force_asyncio_cancelled_error(task) - def get_task(self, client: ClientManager.Client, args: List) -> asyncio.Task: + def get_task( + self, + owner: Hashable, + name: str, + ) -> Task: """ - Given client and task arguments, retrieve the associated task instance. + Attempt to get a task linked to an owner and name. Parameters ---------- - client: ClientManager.Client - Client associated to the task. - args: list - Arguments of the task. + owner : Hashable + Owner of the task. + name : str + Name of the task. Returns ------- - asyncio.Task: - Task object. - """ - - return self.client_tasks[client.id][args[0]][0] + Task + Task that matches the description. - def get_task_args(self, client: ClientManager.Client, args: List) -> List: + Raises + ------ + TaskError.TaskNotFoundError + If no such task exists. """ - Given client and task arguments, retrieve the creation arguments of the task. - Parameters - ---------- - client: ClientManager.Client - Client associated to the task. - args: list - Arguments of the task. + try: + owner_tasks = self.tasks[owner] + except KeyError: + raise TaskError.TaskNotFoundError - Returns - ------- - list: - Task creation arguments. - """ + try: + task = owner_tasks[name] + except KeyError: + raise TaskError.TaskNotFoundError - return self.client_tasks[client.id][args[0]][1] + return task - def get_task_attr(self, client: ClientManager.Client, args: List, attr: str) -> Any: + def is_task( + self, + owner: Hashable, + name: str, + ) -> bool: """ - Given client, task arguments, and an attribute name of a task, retrieve its associated - attribute value. + Return whether there exists a task managed by this manager with given owner and name. Parameters ---------- - client: ClientManager.Client - Client associated to the task. - args: list - Arguments of the task. - attr: str - Attribute name. + owner : Hashable + Owner of the task. + name : str + Name of the task. Returns ------- - Any: - Attribute value - """ - - return self.client_tasks[client.id][args[0]][2][attr] - - def set_task_attr(self, client: ClientManager.Client, args: List, attr: str, value: Any): - """ - Given client, task arguments, attribute name of task and a value, set the attribute to - that value. - - Parameters - ---------- - client: ClientManager.Client - Client associated to the task. - args: list - Arguments of the task. - attr: str - Attribute name. - value: Any - Attribute value. + bool + Whether such a task exists. """ - self.client_tasks[client.id][args[0]][2][attr] = value - - ### - # CURRENTLY SUPPORTED TASKS - ### - - async def await_cancellation(self, old_task: asyncio.Task): - # Wait until it is able to properly retrieve the cancellation exception try: - await old_task - except asyncio.CancelledError: - pass + self.get_task(owner, name) + return True + except TaskError.TaskNotFoundError: + return False - async def do_nothing(self): - while True: - try: - await asyncio.sleep(1) - except KeyboardInterrupt: - raise + ###### + # Currently supported tasks + ###### + + async def as_afk_kick(self, task: Task): + client: ClientManager.Client = task.owner + afk_delay: int = task.parameters['afk_delay'] + afk_sendto: int = task.parameters['afk_sendto'] - async def as_afk_kick(self, client: ClientManager.Client, args: List): - afk_delay, afk_sendto = args try: delay = int(afk_delay)*60 # afk_delay is in minutes, so convert to seconds except (TypeError, ValueError): + # This shouldn't happen with a well-verified area list info = ('The area file contains an invalid AFK kick delay for area {}: {}'. format(client.area.id, afk_delay)) - raise ServerError(info) + raise RuntimeError(info) if delay <= 0: # Assumes 0-minute delay means that AFK kicking is disabled return @@ -216,14 +276,15 @@ async def as_afk_kick(self, client: ClientManager.Client, args: List): raise else: try: - area = client.server.area_manager.get_area_by_id(int(afk_sendto)) + area = client.hub.area_manager.get_area_by_id(int(afk_sendto)) except Exception: + # This shouldn't happen with a well-verified area list info = ('The area file contains an invalid AFK kick destination area for area {}: ' '{}'.format(client.area.id, afk_sendto)) - raise ServerError(info) + raise RuntimeError(info) if client.area.id == afk_sendto: # Don't try and kick back to same area return - if not client.has_character(): # Assumes spectators are exempted from AFK kicks + if not client.has_participant_character(): # Assumes spectators are exempted from AFK kicks return if client.is_staff(): # Assumes staff are exempted from AFK kicks return @@ -253,9 +314,17 @@ async def as_afk_kick(self, client: ClientManager.Client, args: List): for c in p.get_members(): c.send_ooc('{} was AFK kicked from your party.'.format(original_name)) - async def as_day_cycle(self, client: ClientManager.Client, args: List): - _, area_1, area_2, hour_length, hour_start, hours_in_day, send_first_hour = args + async def as_day_cycle(self, task: Task): + client: ClientManager.Client = task.owner + area_1: int = task.parameters['area_1'] + area_2: int = task.parameters['area_2'] + hour_length: int = task.parameters['hour_length'] + hour_start: int = task.parameters['hour_start'] + hours_in_day: int = task.parameters['hours_in_day'] + send_first_hour: bool = task.parameters['send_first_hour'] + hour = hour_start + hub = client.hub # For later, in case client changes hub minute_at_interruption = 0 main_hour_length = hour_length time_started_at = time.time() @@ -263,19 +332,20 @@ async def as_day_cycle(self, client: ClientManager.Client, args: List): periods = list() force_period_refresh = False current_period = (-1, '', main_hour_length) - notify_normies = False + notify_others = False # Initialize task attributes - self.set_task_attr(client, ['as_day_cycle'], 'is_paused', False) - self.set_task_attr(client, ['as_day_cycle'], 'is_unknown', False) - self.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', '') - self.set_task_attr(client, ['as_day_cycle'], 'period', '') - self.set_task_attr(client, ['as_day_cycle'], 'hours_in_day', hours_in_day) - self.set_task_attr(client, ['as_day_cycle'], 'main_hour_length', main_hour_length) + task.parameters['is_paused'] = False + task.parameters['is_unknown'] = False + task.parameters['refresh_reason'] = '' + task.parameters['period'] = '' + task.parameters['hours_in_day'] = hours_in_day + task.parameters['main_hour_length'] = main_hour_length # Manually notify for the very first hour (if needed) - targets = [c for c in self.server.get_clients() if c == client or - ((c.is_staff() or send_first_hour) and area_1 <= c.area.id <= area_2)] + targets = [c for c in client.hub.get_players() if c == client or + ((c.is_staff() or send_first_hour) + and area_1 <= c.area.id <= area_2)] for c in targets: c.send_ooc('It is now {}:00.'.format('{0:02d}'.format(hour))) c.send_clock(client_id=client.id, hour=hour) @@ -294,21 +364,21 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: while True: try: - refresh_reason = self.get_task_attr(client, ['as_day_cycle'], 'refresh_reason') - self.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', '') + refresh_reason = task.parameters['refresh_reason'] + task.parameters['refresh_reason'] = '' # If timer is in unknown phase, there is no time progression # Check again in one second. - if self.get_task_attr(client, ['as_day_cycle'], 'is_unknown'): + if task.parameters['is_unknown']: # Manually restart other flags because they are no longer relevant - notify_normies = True + notify_others = True await asyncio.sleep(1) continue # If timer is paused, check again in one second. - if self.get_task_attr(client, ['as_day_cycle'], 'is_paused'): + if task.parameters['is_paused']: # Manually restart other flags because they are no longer relevant - notify_normies = True + notify_others = True await asyncio.sleep(1) continue @@ -321,11 +391,11 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: # If the clock just had a new period added, its number of hours changed, or was just # unpaused, restart the current hour elif refresh_reason in ['period', 'unpause', 'set_hours_proceed']: - notify_normies = True + notify_others = True await asyncio.sleep((60-minute_at_interruption)/60 * hour_length) # Otherwise, just wait full hour else: - notify_normies = True + notify_others = True await asyncio.sleep(hour_length) # After handling any interrupts, an hour just finished without any @@ -333,16 +403,17 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: # In all cases now, update hour # We can do that as code only runs here if the timer is not paused hour = (hour + 1) % hours_in_day - targets = [c for c in self.server.get_clients() if c == client or - (notify_normies and area_1 <= c.area.id <= area_2)] + targets = [c for c in client.hub.get_players() + if c == client + or (notify_others and area_1 <= c.area.id <= area_2)] # Check if new period has started if not periods: if current_period[1] != '': for c in targets: - self.set_task_attr(client, ['as_day_cycle'], 'period', '') + task.parameters['period'] = '' c.send_time_of_day(name='') - c.send_ooc(f'It is no longer some particular period of day.') + c.send_ooc('It is no longer some particular period of day.') current_period = find_period_of_hour(hour) hour_length = main_hour_length else: @@ -351,7 +422,7 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: hour_length = new_period_length if new_period_start == hour or force_period_refresh: for c in targets: - self.set_task_attr(client, ['as_day_cycle'], 'period', new_period_name) + task.parameters['period'] = new_period_name c.send_time_of_day(name=new_period_name) c.send_ooc(f'It is now {new_period_name}.') force_period_refresh = False @@ -363,7 +434,7 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: time_started_at = time.time() minute_at_interruption = 0 - notify_normies = True + notify_others = True except (asyncio.CancelledError, KeyError): # Code can run here for a few reasons @@ -376,12 +447,14 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: # 7. The clock was just paused time_refreshed_at = time.time() - try: - refresh_reason = self.get_task_attr(client, ['as_day_cycle'], 'refresh_reason') - except KeyError: + if 'refresh_reason' not in task.parameters: + task.parameters['refresh_reason'] = '' + + refresh_reason = task.parameters['refresh_reason'] + + if not refresh_reason: # refresh_reason may be undefined or the empty string. # Both cases imply cancelation - # self.set_task_attr(client, ['as_day_cycle'], 'period', '') # Raises an error! for c in targets: c.send_clock(client_id=client.id, hour=-1) c.send_time_of_day(name='') # Reset time of day @@ -390,26 +463,23 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: client.send_ooc_others('(X) The day cycle initiated by {} in areas {} through ' '{} has been ended.' .format(client.name, area_1, area_2), - is_zstaff_flex=True) - targets = [c for c in self.server.get_clients() if c == client or - area_1 <= c.area.id <= area_2] - + is_zstaff_flex=True, in_hub=hub) + targets = [c for c in client.hub.get_players() + if c == client or area_1 <= c.area.id <= area_2] break if refresh_reason == 'set': old_hour = hour - hour_length, hour = self.get_task_attr(client, ['as_day_cycle'], - 'new_day_cycle_args') + hour_length, hour = task.parameters['new_day_cycle_args'] main_hour_length = hour_length - self.set_task_attr(client, ['as_day_cycle'], 'main_hour_length', - main_hour_length) + task.parameters['main_hour_length'] = main_hour_length # Do not notify of clock set to normies if only hour length changed - notify_normies = (old_hour != hour) + notify_others = (old_hour != hour) minute_at_interruption = 0 force_period_refresh = True str_hour = '{0:02d}'.format(hour) - self.set_task_attr(client, ['as_day_cycle'], 'is_unknown', False) + task.parameters['is_unknown'] = False client.send_ooc('Your day cycle in areas {} through {} was updated. New hour ' 'length: {} seconds. New hour: {}:00.' .format(area_1, area_2, hour_length, str_hour)) @@ -418,14 +488,14 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: 'New hour: {}:00.' .format(client.name, area_1, area_2, hour_length, str_hour), - is_zstaff_flex=True) + is_zstaff_flex=True, in_hub=hub) # Setting time does not unpause the timer, warn clock master - if self.get_task_attr(client, ['as_day_cycle'], 'is_paused'): + if task.parameters['is_paused']: client.send_ooc('(X) Warning: Your day cycle is still paused.') # Moreover, hour is +1'd automatically if the clock is unpaused # So preemptively -1 - if not self.get_task_attr(client, ['as_day_cycle'], 'is_paused'): + if not task.parameters['is_paused']: hour -= 1 # Take one hour away, because an hour would be added anyway # This does not modify the hour length of active periods, so if there are any @@ -437,27 +507,26 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: elif refresh_reason == 'set_hours': old_hour = hour - self.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', - 'set_hours_proceed') + task.parameters['refresh_reason'] = 'set_hours_proceed' # Only update minute and time started at if timer is not paused - if not self.get_task_attr(client, ['as_day_cycle'], 'is_paused'): + if not task.parameters['is_paused']: minute_at_interruption += (time_refreshed_at-time_started_at)/hour_length*60 time_started_at = time.time() - hours_in_day = self.get_task_attr(client, ['as_day_cycle'], 'hours_in_day') + hours_in_day = task.parameters['hours_in_day'] client.send_ooc(f'Your day cycle in areas {area_1} through {area_2} was ' f'updated. New number of hours in the day: {hours_in_day} ' f'hours.') client.send_ooc_others(f'(X) The day cycle initiated by {client.displayname} ' f'[{client.id}] in areas {area_1} through {area_2} has ' f'been updated. New number of hours in the day: ' - f'{hours_in_day} hours.', is_zstaff_flex=True) + f'{hours_in_day} hours.', + is_zstaff_flex=True, in_hub=hub) # Check if current hours exceed new number of hours in the day if hour >= hours_in_day: hour = 0 minute_at_interruption = 0 - self.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', - 'set_hours_reset') + task.parameters['refresh_reason'] = 'set_hours_reset' client.send_ooc(f'(X) The current hour {old_hour} was beyond the new ' f'number of hours in the day you set, so your current hour ' f'was set to 0.') @@ -465,12 +534,13 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: f'{client.displayname} [{client.id}] in areas ' f'{area_1} through {area_2} has had its current ' f'hour be set to 0 because it was beyond the number ' - f'of hours it was set to now have.', is_staff=True, + f'of hours it was set to now have.', + is_zstaff_flex=True, in_hub=hub, pred=lambda c: area_1 <= c.area.id <= area_2) # Moreover, hour is +1'd automatically if the clock is unpaused # So preemptively -1 - if not self.get_task_attr(client, ['as_day_cycle'], 'is_paused'): + if not task.parameters['is_paused']: hour -= 1 # Take one hour away, because an hour would be added anyway # Pop any periods that are beyond the new number of hours in the day @@ -488,39 +558,42 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: f'{area_1} through {area_2} has had the following ' f'periods be removed from the list of periods as ' f'they were beyond the number of hours it was set ' - f'to now have: {popped_periods}.', is_staff=True, + f'to now have: {popped_periods}.', + is_zstaff_flex=True, in_hub=hub, pred=lambda c: area_1 <= c.area.id <= area_2) force_period_refresh = True # Super conservative but always correct. # Setting time does not unpause the timer, warn clock master - if self.get_task_attr(client, ['as_day_cycle'], 'is_paused'): + if task.parameters['is_paused']: client.send_ooc('(X) Warning: Your day cycle is still paused.') elif refresh_reason == 'unknown': hour = -1 - self.set_task_attr(client, ['as_day_cycle'], 'is_unknown', True) + task.parameters['is_unknown'] = True client.send_ooc('You have set the time to be unknown.') client.send_ooc_others(f'(X) The day cycle initiated by {client.displayname} ' f'[{client.id}] in areas {area_1} through {area_2} has ' - f'been set to be at an unknown time.', is_staff=True, + f'been set to be at an unknown time.', + is_zstaff_flex=True, in_hub=hub, pred=lambda c: area_1 <= c.area.id <= area_2) - client.send_ooc_others('You seem to have lost track of time.', is_staff=False, + client.send_ooc_others('You seem to have lost track of time.', + is_zstaff_flex=False, in_hub=hub, pred=lambda c: area_1 <= c.area.id <= area_2) - self.set_task_attr(client, ['as_day_cycle'], 'period', 'unknown') - targets = [c for c in self.server.get_clients() if c == client or - (area_1 <= c.area.id <= area_2)] + task.parameters['period'] = 'unknown' + targets = [c for c in client.hub.get_players() + if c == client or (area_1 <= c.area.id <= area_2)] for c in targets: c.send_clock(client_id=client.id, hour=-1) c.send_time_of_day(name='unknown') elif refresh_reason == 'period': # Only update minute and time started at if timer is not paused - if not self.get_task_attr(client, ['as_day_cycle'], 'is_paused'): + if not task.parameters['is_paused']: minute_at_interruption += (time_refreshed_at-time_started_at)/hour_length*60 time_started_at = time.time() - start, name, length = self.get_task_attr(client, ['as_day_cycle'], 'new_period_start') + start, name, length = task.parameters['new_period_start'] # Pop entries with same start or name if needed (duplicated entries) found = False @@ -549,7 +622,7 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: # Also note this is only relevant if the time is not unknown. If it is, # then no updates should be sent changed_current_period = False - if not self.get_task_attr(client, ['as_day_cycle'], 'is_unknown'): + if not task.parameters['is_unknown']: new_period_start, new_period_name, new_period_length = find_period_of_hour(hour) changed_current_period = (current_period[1] != new_period_name) current_period = new_period_start, new_period_name, new_period_length @@ -557,9 +630,9 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: changed_current_period = True if changed_current_period: - targets = [c for c in self.server.get_clients() - if c == client or area_1 <= c.area.id <= area_2] - self.set_task_attr(client, ['as_day_cycle'], 'period', new_period_name) + targets = [c for c in client.hub.get_players() + if c == client or area_1 <= c.area.id <= area_2] + task.parameters['period'] = new_period_name if new_period_name: for c in targets: c.send_time_of_day(name=new_period_name) @@ -567,7 +640,7 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: else: for c in targets: c.send_time_of_day(name='') - c.send_ooc(f'It is no longer some particular period of day.') + c.send_ooc('It is no longer some particular period of day.') # Send notifications appropriately if start >= 0: @@ -576,38 +649,49 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: client.send_ooc(f'(X) You have added period `{name}`. ' f'Period hour length: {new_period_length} seconds. ' f'Period hour start: {formatted_time}.') - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] has ' - f'added period `{name}` to their day cycle. ' - f'Period hour length: {new_period_length} seconds. ' - f'Period hour start: {formatted_time} ' - f'({client.area.id}).', - is_zstaff_flex=True) + client.send_ooc_others( + f'(X) {client.displayname} [{client.id}] has added period `{name}` ' + f'to their day cycle. ' + f'Period hour length: {new_period_length} seconds. ' + f'Period hour start: {formatted_time} ' + f'({client.area.id}).', + is_zstaff_flex=True, in_hub=hub, + ) else: # Case removed a period client.send_ooc(f'(X) You have removed period `{name}`.') client.send_ooc_others(f'(X) {client.displayname} [{client.id}] has ' - f'removed period `{name}` off their day cycle ' - f'({client.area.id}).', - is_zstaff_flex=True) + f'removed period `{name}` off their day cycle ' + f'({client.area.id}).', + is_zstaff_flex=True, in_hub=hub) elif refresh_reason == 'unpause': - self.set_task_attr(client, ['as_day_cycle'], 'is_paused', False) + task.parameters['is_paused'] = False client.send_ooc('Your day cycle in areas {} through {} has been unpaused.' .format(area_1, area_2)) client.send_ooc_others('(X) The day cycle initiated by {} in areas {} through ' '{} has been unpaused.' .format(client.name, area_1, area_2), - is_zstaff_flex=True) + is_zstaff_flex=True, in_hub=hub) time_started_at = time.time() igt_now = '{}:{}'.format('{0:02d}'.format(hour), '{0:02d}'.format(int(minute_at_interruption))) + igt_rounded = '{}:00.'.format('{0:02d}'.format(hour)) client.send_ooc('It is now {}.'.format(igt_now)) - client.send_ooc_others('It is now {}.'.format(igt_now), is_staff=True, + client.send_ooc_others('It is now {}.'.format(igt_now), + is_zstaff_flex=True, in_hub=hub, pred=lambda c: area_1 <= c.area.id <= area_2) + client.send_ooc_others('It is now some time past {}.'.format(igt_rounded), + is_zstaff_flex=False, in_hub=hub, + pred=lambda c: area_1 <= c.area.id <= area_2) + targets = [c for c in client.hub.get_players() + if c == client or area_1 <= c.area.id <= area_2] + for c in targets: + c.send_clock(client_id=client.id, hour=hour) elif refresh_reason == 'pause': - self.set_task_attr(client, ['as_day_cycle'], 'is_paused', True) + task.parameters['is_paused'] = True minute_at_interruption += (time_refreshed_at - time_started_at)/hour_length*60 igt_now = '{}:{}'.format('{0:02d}'.format(hour), @@ -617,12 +701,15 @@ def find_period_of_hour(hour) -> Tuple[int, str, int]: client.send_ooc_others('(X) The day cycle initiated by {} in areas {} through ' '{} has been paused at {}.' .format(client.name, area_1, area_2, igt_now), - is_zstaff_flex=True) + is_zstaff_flex=True, in_hub=hub) else: raise ValueError(f'Unknown refresh reason {refresh_reason} for day cycle.') - async def as_effect(self, client: ClientManager.Client, args: List): - _, length, effect, new_value = args # Length in seconds, already converted + async def _as_effect(self, task: Task): + client: ClientManager.Client = task.owner + length: int = task.parameters['length'] # Length in seconds, already converted + effect: Effects = task.parameters['effect'] + new_value: bool = task.parameters['new_value'] try: await asyncio.sleep(length) @@ -641,19 +728,22 @@ async def as_effect(self, client: ClientManager.Client, args: List): .format(client.displayname, client.id, effect.name), is_zstaff_flex=True) effect.function(client, False) - self.remove_task(client, [effect.async_name]) - async def as_effect_blindness(self, client: ClientManager.Client, args: List): - await self.as_effect(client, args+[True]) + async def as_effect_blindness(self, task: Task): + await self._as_effect(task) + + async def as_effect_deafness(self, task: Task): + await self._as_effect(task) - async def as_effect_deafness(self, client: ClientManager.Client, args: List): - await self.as_effect(client, args+[True]) + async def as_effect_gagged(self, task: Task): + await self._as_effect(task) - async def as_effect_gagged(self, client: ClientManager.Client, args: List): - await self.as_effect(client, args+[True]) + async def as_handicap(self, task: Task): + client: ClientManager.Client = task.owner + length: int = task.parameters['length'] + handicap_name: str = task.parameters['handicap_name'] + announce_if_over: bool = task.parameters['announce_if_over'] - async def as_handicap(self, client: ClientManager.Client, args: List): - _, length, _, announce_if_over = args client.is_movement_handicapped = True try: @@ -666,27 +756,33 @@ async def as_handicap(self, client: ClientManager.Client, args: List): finally: client.is_movement_handicapped = False - async def as_timer(self, client: ClientManager.Client, args: List): - _, length, name, is_public = args # Length in seconds, already converted + async def as_timer(self, task: Task): + client: ClientManager.Client = task.owner + length: int = task.parameters['length'] # Length in seconds, already converted + timer_name: str = task.parameters['timer_name'] + is_public: bool = task.parameters['is_public'] + client_name = client.name # Failsafe in case disconnection before task is cancelled/expires try: await asyncio.sleep(length) except asyncio.CancelledError: - client.send_ooc(f'Your timer {client_name} has been ended.') - client.send_ooc_others(f'Timer "{name}" initiated by {client_name} has been ended.', - pred=lambda c: (c.is_staff() or - (is_public and c.area == client.area))) + client.send_ooc(f'Your timer {timer_name} has been ended.') + client.send_ooc_others( + f'Timer "{timer_name}" initiated by {client_name} has been ended.', + pred=lambda c: (c.is_staff() or (is_public and c.area == client.area))) else: - client.send_ooc(f'Your timer {client_name} has expired.') - client.send_ooc_others(f'Timer "{name}" initiated by {client_name} has expired.', - pred=lambda c: (c.is_staff() or - (is_public and c.area == client.area))) + client.send_ooc(f'Your timer {timer_name} has expired.') + client.send_ooc_others( + f'Timer "{timer_name}" initiated by {timer_name} has expired.', + pred=lambda c: (c.is_staff() or (is_public and c.area == client.area))) finally: - del self.active_timers[name] + del self.active_timers[timer_name] + + async def as_lurk(self, task: Task): + client: ClientManager.Client = task.owner + length: int = task.parameters['length'] - async def as_lurk(self, client: ClientManager.Client, args: List): - length, = args # The lurk callout timer once it finishes will restart itself except if cancelled while True: try: @@ -711,8 +807,10 @@ async def as_lurk(self, client: ClientManager.Client, args: List): is_zstaff_flex=False, in_area=True, pred=lambda c: not (c.is_blind and c.is_deaf)) - async def as_phantom_peek(self, client: ClientManager.Client, args: List): - length, = args + async def as_phantom_peek(self, task: Task): + client: ClientManager.Client = task.owner + length: int = task.parameters['length'] + try: await asyncio.sleep(length) except asyncio.CancelledError: @@ -728,6 +826,6 @@ async def as_phantom_peek(self, client: ClientManager.Client, args: List): return if client.is_staff(): return - if not client.has_character(): + if not client.has_participant_character(): return client.send_ooc('You feel as though you are being peeked on.') diff --git a/server/timer_manager.py b/server/timer_manager.py index 9eb964761..a850f1600 100644 --- a/server/timer_manager.py +++ b/server/timer_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -234,8 +235,8 @@ def __init__(self, server, manager, timer_id, tick_rate=None, start_value=None, if tick_rate == 0: raise TimerError.InvalidTickRateError - self._server = server - self._manager = manager + self.server = server + self.manager = manager self._id = timer_id self._tick_rate = tick_rate @@ -389,7 +390,7 @@ def terminate(self) -> float: if self._auto_destroy: try: - self._manager.delete_timer(self) + self.manager.delete_timer(self) except TimerError.ManagerDoesNotManageTimerError: # This should only happen if the .terminate call came from a call to manager's # .delete_timer, at which point it is meaningless to attempt to make the manager @@ -751,36 +752,43 @@ def _check_structure(self): timer_value = self._get() # 1. - err = (f'Expected the timer minimum timer value be a non-negative number, found ' - f'it was {self._min_value} instead.') - assert self._min_value >= 0, err + assert self._min_value >= 0, ( + f'Expected the timer minimum timer value be a non-negative number, found ' + f'it was {self._min_value} instead. || {self}' + ) - err = (f'Expected the timer value be at least the minimum timer value ' - f'{self._min_value}, found it was {timer_value} instead.') - assert timer_value >= self._min_value - epsilon, err + assert timer_value >= self._min_value - epsilon, ( + f'Expected the timer value be at least the minimum timer value ' + f'{self._min_value}, found it was {timer_value} instead. || {self}' + ) - err = (f'Expected the timer value be at most the maximum timer value ' - f'{self._max_value}, found it was {timer_value} instead.') - assert timer_value <= self._max_value + epsilon, err + assert timer_value <= self._max_value + epsilon, ( + f'Expected the timer value be at most the maximum timer value ' + f'{self._max_value}, found it was {timer_value} instead. || {self}' + ) # 2. - err = (f'Expected the default timer minimum timer value be a non-negative number, ' - f'found it was {self.DEF_MIN_TIMER_VALUE} instead.') - assert self.DEF_MIN_TIMER_VALUE >= 0, err - - err = (f'Expected the default timer value be at least the default minimum ' - f'timer value {self.DEF_MIN_TIMER_VALUE}, found it was ' - f'{self.DEF_START_TIMER_VALUE}.') - assert self.DEF_START_TIMER_VALUE >= self.DEF_MIN_TIMER_VALUE, err - - err = (f'Expected the default timer value be at most the default maximum ' - f'timer value {self.DEF_MAX_TIMER_VALUE}, found it was ' - f'{self.DEF_START_TIMER_VALUE} instead.') - assert self.DEF_START_TIMER_VALUE <= self.DEF_MAX_TIMER_VALUE, err + assert self.DEF_MIN_TIMER_VALUE >= 0, ( + f'Expected the default timer minimum timer value be a non-negative number, ' + f'found it was {self.DEF_MIN_TIMER_VALUE} instead. || {self}' + ) + + assert self.DEF_START_TIMER_VALUE >= self.DEF_MIN_TIMER_VALUE, ( + f'Expected the default timer value be at least the default minimum timer value ' + f'{self.DEF_MIN_TIMER_VALUE}, found it was {self.DEF_START_TIMER_VALUE} instead. ' + f'|| {self}.' + ) + + assert self.DEF_START_TIMER_VALUE <= self.DEF_MAX_TIMER_VALUE, ( + f'Expected the default timer value be at most the default maximum timer value ' + f'{self.DEF_MAX_TIMER_VALUE}, found it was {self.DEF_START_TIMER_VALUE} instead. ' + f'|| {self}' + ) # 3. - err = 'Expected the tick rate be non-zero, found it was zero.' - assert self._tick_rate != 0 + assert self._tick_rate != 0, ( + f'Expected the tick rate be non-zero, found it was zero. || {self}' + ) def __str__(self): """ @@ -807,7 +815,7 @@ def __repr__(self): """ - return (f'Timer(server, {self._manager.get_id()}, "{self.get_id()}", ' + return (f'Timer(server, {self.manager.get_id()}, "{self.get_id()}", ' f'tick_rate={self._tick_rate}, ' f'min_value={self._min_value}, ' f'max_value={self._max_value}, ' @@ -853,7 +861,7 @@ class TimerManager(): # 1. If `self._timer_limit` is an int, then `len(self._id_to_timer) <=` # `self._timer_limit`. # 2. For every tuple `(timer_id, timer)` in `self._id_to_group.items()`: - # a. `timer._manager = self`. + # a. `timer.manager = self`. # b. `timer.get_id() = timer_id`. # 3. For every pair of distinct timers `timer1` and `timer2` in # `self._id_to_timer.values()`: @@ -878,11 +886,12 @@ def __init__(self, server, timer_limit=None, default_timer_type=None): if default_timer_type is None: default_timer_type = Timer - self._server = server + self.server = server self._timer_limit = timer_limit self._default_timer_type = default_timer_type self._id_to_timer = dict() + self._id = hex(id(self)) def get_id(self): """ @@ -896,10 +905,10 @@ def get_id(self): """ - return hex(id(self)) + return self._id def new_timer(self, timer_type=None, start_value=None, tick_rate=1, - min_value=None, max_value=None, auto_restart=False, auto_destroy=True): + min_value=None, max_value=None, auto_restart=False, auto_destroy=True) -> Timer: """ Create a new timer with given parameters managed by this manager. @@ -955,7 +964,7 @@ def new_timer(self, timer_type=None, start_value=None, tick_rate=1, # Generate a timer ID and the new timer timer_id = self.get_available_timer_id() - timer = timer_type(self._server, self, timer_id, + timer = timer_type(self.server, self, timer_id, start_value=start_value, tick_rate=tick_rate, min_value=min_value, @@ -1129,24 +1138,26 @@ def _check_structure(self): # 1. if self._timer_limit is not None: - err = (f'For timer manager {self}, expected that it managed at most ' - f'{self._timer_limit} timers, but found it managed ' - f'{len(self._id_to_timer)} timers. || {self}') - assert len(self._id_to_timer) <= self._timer_limit, err + assert len(self._id_to_timer) <= self._timer_limit, ( + f'For timer manager {self._id}, expected that it managed at most ' + f'{self._timer_limit} timers, but found it managed {len(self._id_to_timer)} ' + f'timers. || {self}' + ) # 2. for (timer_id, timer) in self._id_to_timer.items(): # 2a. - err = (f'For timer manager {self}, expected that its managed timer ' - f'{timer} recognized that it was managed by it, but found it did not. ' - f'|| {self}') - assert timer._manager == self, err + assert timer.manager == self, ( + f'For timer manager {self._id}, expected that its managed timer {timer} recognized ' + f'that it was managed by it, but found it did not. || {self}' + ) # 2b. - err = (f'For timer manager {self}, expected that timer {timer} ' - f'that appears in the ID to timer mapping has the same ID as in the ' - f'mapping, but found it did not. || {self}') - assert timer.get_id() == timer_id, err + assert timer.get_id() == timer_id, ( + f'For timer manager {self._id}, expected that timer {timer} that appears in the ' + f'ID to timer mapping has the same ID as in the mapping, but found it did not. ' + f'|| {self}' + ) # 3. for timer1 in self._id_to_timer.values(): @@ -1155,10 +1166,10 @@ def _check_structure(self): continue # 3a. - err = (f'For timer manager {self}, expected that its two managed timers ' - f'{timer1}, {timer2} had unique timer IDS, but found ' - f'they did not. || {self}') - assert timer1.get_id() != timer2.get_id(), err + assert timer1.get_id() != timer2.get_id(), ( + f'For timer manager {self}, expected that its two managed timers {timer1}, ' + f'{timer2} had unique timer ids, but found they did not. || {self}' + ) # Last. for timer in self._id_to_timer.values(): diff --git a/server/trial_manager.py b/server/trial_manager.py index 78b772e9a..13e8842e3 100644 --- a/server/trial_manager.py +++ b/server/trial_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -21,1113 +22,2930 @@ """ -import functools +from __future__ import annotations -from server.exceptions import TrialError, GameError -from server.gamewithareas_manager import GameWithAreas, GameWithAreasManager -from server.trialminigame import TrialMinigame, TRIALMINIGAMES -from server.nonstopdebate import NonStopDebate +import typing +from server.exceptions import NonStopDebateError, TrialError, HubbedGameError +from server.hubbedgame_manager import _HubbedGame, HubbedGameManager +from server.trialminigame import _TrialMinigame, TRIALMINIGAMES +from server.nonstopdebate import _NonStopDebate -class _Trial(GameWithAreas): +from typing import Callable, Dict, Set, Any, Tuple, Type, Union + +if typing.TYPE_CHECKING: + from server.area_manager import AreaManager + from server.client_manager import ClientManager + from server.game_manager import _Team + from server.hub_manager import _Hub + from server.timer_manager import Timer + from server.tsuserver import TsuserverDR + +class _TrialTrivialInherited(_HubbedGame): + """ + This class should not be instantiated. """ - A trial is a game with areas that can manage 'trial minigames', which are the following - trial games (server.trialminigame): - * Non-Stop Debates (server.nonstopdebate). - While multiple minigames may be going on at the same time, no player may be part of two - minigames simultaneously. + def get_id(self) -> str: + """ + Return the ID of this trial. - Attributes - ---------- - listener : Listener - Standard listener of the trial. + Returns + ------- + str + The ID. - Callback Methods - ---------------- - _on_area_client_left_final - Method to perform once a client left an area of the trial. - _on_area_client_entered_final - Method to perform once a client entered an area of the trial. - _on_area_destroyed - Method to perform once an area of the trial is marked for destruction. - _on_client_inbound_ms_check - Method to perform once a player of the trial wants to send an IC message. - _on_client_inbound_ms_final - Method to perform once a player of the trial sends an IC message. - _on_client_change_character - Method to perform once a player of the trial has changed character. - _on_client_destroyed - Method to perform once a player of the trial is destroyed. + """ - """ + return super().get_id() - # (Private) Attributes - # -------------------- - # _minigame_manager : GameManager - # Manager for all games of the trial. - # - # Invariants - # ---------- - # 1. For each player of a minigame of this trial, they are also a player of the trial. - # 2. For each area of a minigame of this trial, they are also an area of the trial. - # 3. The player to influence and player to focus maps contain exactly the IDs of all players - # of the trial. - # 4. For each influence and focus value in the player to influence and player to focus maps, - # they are a value between 0 and 10 inclusive. - # 5. The invariants from the parent class GameWithArea are satisfied. + def get_numerical_id(self) -> int: + """ + Return the numerical portion of the ID of this trial. - def __init__(self, server, manager, trial_id, player_limit=None, player_concurrent_limit=None, - require_invitations=False, require_players=True, require_leaders=True, - require_character=False, team_limit=None, timer_limit=None, - area_concurrent_limit=None, autoadd_on_client_enter=False, - autoadd_minigame_on_player_added=False, - minigame_limit=1, playergroup_manager=None): + Returns + ------- + int + Numerical portion of the ID. """ - Create a new trial. A trial should not be fully initialized anywhere else other than - some manager code, as otherwise the manager will not recognize the trial. - Parameters - ---------- - server : TsuserverDR - Server the trial belongs to. - manager : TrialManager - Manager for this trial. - trial_id : str - Identifier of the trial. - player_limit : int or None, optional - If an int, it is the maximum number of players the trial supports. If None, it - indicates the trial has no player limit. Defaults to None. - player_concurrent_limit : int or None, optional - If an int, it is the maximum number of trials managed by `manager` that any - player of this trial may belong to, including this trial. If None, it indicates - that this trial does not care about how many other trials managed by `manager` each - of its players belongs to. Defaults to None. - require_invitation : bool, optional - If True, players can only be added to the trial if they were previously invited. If - False, no checking for invitations is performed. Defaults to False. - require_players : bool, optional - If True, if at any point the trial has no players left, the trial will - automatically be deleted. If False, no such automatic deletion will happen. - Defaults to True. - require_leaders : bool, optional - If True, if at any point the trial has no leaders left, the trial will choose a - leader among any remaining players left; if no players are left, the next player - added will be made leader. If False, no such automatic assignment will happen. - Defaults to True. - require_character : bool, optional - If False, players without a character will not be allowed to join the trial, and - players that switch to something other than a character will be automatically - removed from the trial. If False, no such checks are made. A player without a - character is considered one where player.has_character() returns False. Defaults - to False. - team_limit : int or None, optional - If an int, it is the maximum number of teams the trial supports. If None, it - indicates the trial has no team limit. Defaults to None. - timer_limit : int or None, optional - If an int, it is the maximum number of timers the trial supports. If None, it - indicates the trial has no timer limit. Defaults to None. - area_concurrent_limit : int or None, optional - If an int, it is the maximum number of trials managed by `manager` that any - area of this trial may belong to, including this trial. If None, it indicates - that this game does not care about how many other trials managed by - `manager` each of its areas belongs to. Defaults to 1 (an area may not be a part of - another trial managed by `manager` while being an area of this trial). - autoadd_on_client_enter : bool, optional - If True, nonplayer users that enter an area part of the game will be automatically - added if permitted by the conditions of the game. If False, no such adding will take - place. Defaults to False. - autoadd_minigame_on_player_added: bool, optional - If True, any player added to the trial will be automatically added as a player of the - latest minigame currently open in the trial. If no such minigame is open or the - player addition fails, no action is taken. If False, no such adding will take place. - Defaults to False. - minigame_limit : int or None, optional - If an int, it is the maximum number of minigames the trial may have simultaneously. - If None, it indicates the trial has no minigame limit. Defaults to 1. - playergroup_manager : PlayerGroupManager, optional - The internal playergroup manager of the trial manager. Access to this value is - limited exclusively to this __init__, and is only to initialize the internal - player group of the trial. + return super().get_numerical_id() - Raises - ------ - GameError.ManagerTooManyGamesError - If the manager is already managing its maximum number of games. + def get_name(self) -> str: + """ + Get the name of the trial. + Returns + ------- + str + Name. """ - self._player_to_influence = dict() - self._player_to_focus = dict() - self._min_influence = 0 - self._max_influence = 10 - self._min_focus = 0 - self._max_focus = 10 - self._manager = None # This is set in the super().__init__ + return super().get_name() - self._client_timer_id = 0 + def set_name(self, name: str): + """ + Set the name of the trial. + + Parameters + ---------- + name : str + Name. + """ - self._minigame_manager = GameWithAreasManager( - server, game_limit=minigame_limit, default_game_type=TrialMinigame, - available_id_producer=self.get_available_minigame_id) - super().__init__(server, manager, trial_id, player_limit=player_limit, - player_concurrent_limit=player_concurrent_limit, - require_invitations=require_invitations, - require_players=require_players, - require_leaders=require_leaders, - require_character=require_character, - team_limit=team_limit, timer_limit=timer_limit, - area_concurrent_limit=area_concurrent_limit, - autoadd_on_client_enter=autoadd_on_client_enter, - playergroup_manager=playergroup_manager) + self.unchecked_set_name(name) + self.manager._check_structure() - self._autoadd_minigame_on_player_added = autoadd_minigame_on_player_added + def unchecked_set_name(self, name: str): + """ + Set the name of the trial. - self.listener.update_events({ - 'client_inbound_rt': self._on_client_inbound_rt, - }) + This method does not assert structural integrity. - def get_name(self) -> str: + Parameters + ---------- + name : str + Name. """ - Return the name of the game. Names are fully lowercase. - Implementations of the class should replace this with a human readable name of the trial. + + super().unchecked_set_name(name) + + def get_player_limit(self) -> Union[int, None]: + """ + Return the player membership limit of this trial. Returns ------- - str - Name of the game. + Union[int, None] + The player membership limit. """ - return "trial" + return super().get_player_limit() - def get_autoadd_minigame_on_player_added(self) -> bool: + def get_player_concurrent_limit(self) -> Union[int, None]: """ - Get the default behavior to do when a player is added to a trial: - * If True, the trial will automatically try to add any players that are added to the - trial to all minigames it hosts in some arbitrary order. - * If False, no such automatic attempts are done. + Return the concurrent player membership limit of this trial. Returns ------- - bool - True if an attempt will be made, False otherwise. + Union[int, None] + The concurrent player membership limit. """ - return self._autoadd_minigame_on_player_added + return super().get_player_concurrent_limit() - def add_player(self, user): + def get_players( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ - Make a user a player of the trial. By default this player will not be a leader. It will - also subscribe the trial ot the player so it can listen to its updates. - - Newly added players will be ordered to switch to a 'trial' vasriant. + Return (a shallow copy of) the set of players of this trial that satisfy a + condition if given. Parameters ---------- - user : ClientManager.Client - User to add to the trial. They must be in an area part of the trial. + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all players returned satisfy. Defaults to None (no checked + conditions). - Raises - ------ - GameError.GameIsUnmanagedError - If the trial was scheduled for deletion and thus does not accept any mutator - public method calls. - GameWithAreasError.UserNotInAreaError - If the user is not in an area part of the trial. - GameError.UserHasNoCharacterError - If the user has no character but the trial requires that all players have - characters. - GameError.UserNotInvitedError - If the trial requires players be invited to be added and the user is not invited. - GameError.UserAlreadyPlayerError - If the user to add is already a user of the trial. - GameError.UserHitGameConcurrentLimitError - If the player has reached any of the trials it belongs to managed by this trial's - manager concurrent player membership limit, or by virtue of joining this trial they - will violate this trial's concurrent player membership limit. - GameError.GameIsFullError - If the trial reached its player limit. + Returns + ------- + Set[ClientManager.Client] + The (filtered) players of this trial. """ - # By checking this early, self._player_to_influence will never be overwritten if the player - # was already part of the trial (nor self._player_to_focus) - if self.is_player(user): - raise GameError.UserAlreadyPlayerError - - self._player_to_influence[user.id] = (self._max_influence, self._min_influence, - self._max_influence) - self._player_to_focus[user.id] = (self._max_focus, self._min_focus, self._max_focus) - try: - super().add_player(user) - except GameError as ex: - # Remove entries in player to influence and player to focus maps before reraising - self._player_to_influence.pop(user.id) - self._player_to_focus.pop(user.id) - raise ex - - self.introduce_user(user) - self.publisher.publish('trial_player_added', { - 'player': user, - }) + return super().get_players(cond=cond) - def introduce_user(self, user): + def is_player(self, user: ClientManager.Client) -> bool: """ - Broadcast information relevant for a user entering an area of the trial, namely current - gamemode if needed. - Note the user needs not be in the same area as the trial, nor be a player of the trial. + Decide if a user is a player of the trial. Parameters ---------- user : ClientManager.Client - User to introduce. + User to test. Returns ------- - None. + bool + True if the user is a player, False otherwise. """ - if self.is_player(user): - user.send_health(side=1, health=int(self._player_to_focus[user.id][0])) - user.send_health(side=2, health=int(self._player_to_influence[user.id][0])) - - # If there are any minigames, let them set the splashes, gamemode and timers - if self.get_minigames(): - return - - user.send_gamemode(name='trial') - user.send_splash(name='testimony1') - user.send_timer_pause(timer_id=self._client_timer_id) - user.send_timer_set_time(timer_id=self._client_timer_id, new_time=0) - user.send_timer_set_step_length(timer_id=self._client_timer_id, - new_step_length=0) - user.send_timer_set_firing_interval(timer_id=self._client_timer_id, - new_firing_interval=0) + return super().is_player(user) - def remove_player(self, user): + def add_player(self, user: ClientManager.Client): """ - Make a user be no longer a player of this trial. If they were part of a team managed by - this trial, they will also be removed from said team. It will also unsubscribe the game - from the player so it will no longer listen to its updates, and remove them from any - minigames them may have been a part. + Make a user a player of the trial. By default this player will not be a leader, + unless the trial has no leaders and it requires a leader. + It will also subscribe the trial to the player so it can listen to its updates. - If the game required that there it always had players and by calling this method the - game had no more players, the game will automatically be scheduled for deletion. A similar - check will be performed for each minigame the user may have belonged to. + Newly added players will be ordered to switch to a 'trial' variant. Parameters ---------- user : ClientManager.Client - User to remove. + User to add to the trial. They must be in an area part of the trial. Raises ------ - GameError.GameIsUnmanagedError - If the game was scheduled for deletion and thus does not accept any mutator + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator public method calls. - GameError.UserNotPlayerError - If the user to remove is already not a player of this game. + TrialError.UserNotInAreaError + If the user is not in an area part of the trial. + TrialError.UserHasNoCharacterError + If the user has no character but the trial requires that all players have + characters. + TrialError.UserNotInvitedError + If the trial requires players be invited to be added and the user is not + invited. + TrialError.UserAlreadyPlayerError + If the user to add is already a user of the trial. + TrialError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the trial + managed by the manager of this trial, or by virtue of joining this + trial they would violate this trial's concurrent player membership limit. + TrialError.GameIsFullError + If the trial reached its player limit. """ - if not self.is_player(user): - raise GameError.UserNotPlayerError - - self._player_to_influence.pop(user.id) - self._player_to_focus.pop(user.id) - for game in self._minigame_manager.get_games(): - if user in game.get_players(): - game.remove_player(user) - - super().remove_player(user) + self.unchecked_add_player(user) + self.manager._check_structure() - self.dismiss_user(user) - - # # If the trial was destroyed because it lost all its players, warn server officers - # if self.is_unmanaged(): - # user.send_ooc('As you were the last member of your trial, it was automatically ' - # 'destroyed.') - # user.send_ooc_others(f'(X) Trial {self.get_id()} was automatically destroyed as it ' - # f'lost all its players.', is_officer=True) - - def dismiss_user(self, user): + def remove_player(self, user: ClientManager.Client): """ - Broadcast information relevant for a user that has left the trial, namely clear out - gamemode and health bars. Gamemode is only cleared if the user's new area is not part - of the trial's areas. - Note the user needs not be in the same area as the NSD, nor be a player of the NSD. - If the trial has never had any players, this method does nothing. + Make a user be no longer a player of this trial. If they were part of a team + managed by this trial, they will also be removed from said team. It will also + unsubscribe the trial from the player so it will no longer listen to its updates. + + If the trial required that there it always had players and by calling this method + the trial had no more players, the trial will automatically be scheduled + for deletion. Parameters ---------- user : ClientManager.Client - User to introduce. + User to remove. - Returns - ------- - None. + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.UserNotPlayerError + If the user to remove is already not a player of this trial. """ - if not self.has_ever_had_players(): - return + self.unchecked_remove_player(user) + self.manager._check_structure() - # We use .new_area rather than .area as this function may have been called as a result - # of the user moving, in which case .area still points to the user's old area. + def requires_players(self) -> bool: + """ + Return whether the trial requires players at all times. - user.send_health(side=1, health=user.area.hp_pro) - user.send_health(side=2, health=user.area.hp_def) + Returns + ------- + bool + Whether the trial requires players at all times. + """ - # If the user is no longer in an area part of an area of the trial, clear out gamemode - if user.new_area not in self.get_areas(): - user.send_gamemode(name='') + return super().requires_players() - def add_area(self, area): + def get_invitations( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ - Add an area to this trial's set of areas. + Return (a shallow copy of) the set of invited users of this trial that satisfy a + condition if given. Parameters ---------- - area : AreaManager.Area - Area to add. - - Raises - ------ - TrialError.AreaDisallowsBulletsError - If the area to add disallows bullets. - GameError.GameIsUnmanagedError - If the game was scheduled for deletion and thus does not accept any mutator - public method calls. - GameWithAreasError.AreaAlreadyInGameError - If the area is already part of the game. - GameWithAreasError.AreaHitGameConcurrentLimitError. - If `area` has reached the concurrent area membership limit of any of the games it - belongs to managed by this manager, or by virtue of adding this area it will violate - this game's concurrent area membership limit. + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all invited users returned satisfy. Defaults to None (no checked + conditions). Returns ------- - None. + Set[ClientManager.Client] + The (filtered) invited users of this trial. """ - if not area.bullet: - raise TrialError.AreaDisallowsBulletsError - - super().add_area(area) + return super().get_invitations(cond=cond) - def get_influence(self, user) -> float: + def is_invited(self, user: ClientManager.Client) -> bool: """ - Get the current influence of a player of the trial. + Decide if a user is invited to the trial. Parameters ---------- user : ClientManager.Client - Player to check. + User to test. Raises ------ - TrialError.UserNotPlayerError - If the user is not a player of the trial. + TrialError.UserAlreadyPlayerError + If the user is a player of this trial. Returns ------- - float - Current influence of the player. + bool + True if the user is invited, False otherwise. """ try: - return self._player_to_influence[user.id][0] - except KeyError: - raise TrialError.UserNotPlayerError + return super().is_invited(user) + except HubbedGameError.UserAlreadyPlayerError: + raise TrialError.UserAlreadyPlayerError - def set_influence(self, user, new_influence): + def add_invitation(self, user: ClientManager.Client): """ - Set the influence of a player of the trial. + Mark a user as invited to this trial. Parameters ---------- user : ClientManager.Client - Client to change. - new_influence : float - New influence. + User to invite to the trial. Raises ------ - TrialError.UserNotPlayerError - If the user is not a player of the trial. - TrialError.InfluenceIsInvalidError - If the new influence is below the trial minimum or above the trial maximum. + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameDoesNotTakeInvitationsError + If the trial does not require users be invited to the trial. + TrialError.UserAlreadyInvitedError + If the player to invite is already invited to the trial. + TrialError.UserAlreadyPlayerError + If the player to invite is already a player of the trial. - Returns - ------- - None. + """ + + self.unchecked_add_invitation(user) + self.manager._check_structure() + def unchecked_add_invitation(self, user: ClientManager.Client): """ + Mark a user as invited to this trial. - if user not in self.get_players(): - raise TrialError.UserNotPlayerError - _, min_influence, max_influence = self._player_to_influence[user.id] + This method does not assert structural integrity. - if not min_influence <= new_influence <= max_influence: - raise TrialError.InfluenceIsInvalidError + Parameters + ---------- + user : ClientManager.Client + User to invite to the trial. - self._player_to_influence[user.id] = (new_influence, min_influence, max_influence) - user.send_health(side=2, health=int(new_influence)) + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameDoesNotTakeInvitationsError + If the trial does not require users be invited to the trial. + TrialError.UserAlreadyInvitedError + If the player to invite is already invited to the trial. + TrialError.UserAlreadyPlayerError + If the player to invite is already a player of the trial. - # If the new influence is 0, warn all trial leaders - if new_influence == 0: - user.send_ooc('You ran out of influence!') - user.send_ooc_others(f'(X) {user.displayname} ran out of influence!', - pred=lambda c: c in self.get_leaders()) - self._check_structure() + """ + + try: + super().unchecked_add_invitation(user) + except HubbedGameError.GameIsUnmanagedError: + raise TrialError.GameIsUnmanagedError + except HubbedGameError.GameDoesNotTakeInvitationsError: + raise TrialError.GameDoesNotTakeInvitationsError + except HubbedGameError.UserAlreadyInvitedError: + raise TrialError.UserAlreadyInvitedError + except HubbedGameError.UserAlreadyPlayerError: + raise TrialError.UserAlreadyPlayerError - def change_influence_by(self, user, change_by): + def remove_invitation(self, user: ClientManager.Client): """ - Change the influence of a player by a certain value. If the new influence value goes - below the trial minimum, it is set to the trial minimum. If instead it goes above the - trial maximum, it is set to the trial maximum. + Mark a user as no longer invited to this trial (uninvite). Parameters ---------- user : ClientManager.Client - Client to change. - change_by : float - Amount to change influence by. + User to uninvite. Raises ------ - TrialError.UserNotPlayerError - If the user is not a player of the trial. - - Returns - ------- - None. + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameDoesNotTakeInvitationsError + If the trial does not require users be invited to the trial. + TrialError.UserNotInvitedError + If the user to uninvite is already not invited to this trial. """ - if user not in self.get_players(): - raise TrialError.UserNotPlayerError - - new_influence = self._player_to_influence[user.id][0] + change_by - new_influence = max(self._min_influence, min(self._max_influence, new_influence)) - self.set_influence(user, new_influence) # Also calls _check_structure() + self.unchecked_remove_invitation(user) + self.manager._check_structure() - def get_min_influence(self, user) -> float: + def unchecked_remove_invitation(self, user: ClientManager.Client): """ - Get the current minimum influence of a player of the trial. + Mark a user as no longer invited to this trial (uninvite). + + This method does not assert structural integrity. Parameters ---------- user : ClientManager.Client - Player to check. + User to uninvite. Raises ------ - TrialError.UserNotPlayerError - If the user is not a player of the trial. + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameDoesNotTakeInvitationsError + If the trial does not require users be invited to the trial. + TrialError.UserNotInvitedError + If the user to uninvite is already not invited to this trial. + + """ + + try: + super().unchecked_remove_invitation(user) + except HubbedGameError.GameIsUnmanagedError: + raise TrialError.GameIsUnmanagedError + except HubbedGameError.GameDoesNotTakeInvitationsError: + raise TrialError.GameDoesNotTakeInvitationsError + except HubbedGameError.UserNotInvitedError: + raise TrialError.UserNotInvitedError + + def requires_invitations(self): + """ + Return True if the trial requires players be invited before being allowed to join + the trial, False otherwise. Returns ------- - float - Current minimum influence of the player. - + bool + True if the trial requires players be invited before being allowed to join + the trial, False otherwise. """ - try: - return self._player_to_influence[user.id][1] - except KeyError: - raise TrialError.UserNotPlayerError + return super().requires_invitations() - def get_max_influence(self, user) -> float: + def get_leaders( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ - Get the current maximum influence of a player of the trial. + Return (a shallow copy of) the set of leaders of this trial that satisfy a condition + if given. Parameters ---------- - user : ClientManager.Client - Player to check. - - Raises - ------ - TrialError.UserNotPlayerError - If the user is not a player of the trial. + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all leaders returned satisfy. Defaults to None (no checked + conditions). Returns ------- - float - Current maximum influence of the player. + Set[ClientManager.Client] + The (filtered) leaders of this trial. """ - try: - return self._player_to_influence[user.id][2] - except KeyError: - raise TrialError.UserNotPlayerError + return super().get_leaders(cond=cond) - def get_focus(self, user) -> float: + def get_regulars( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: """ - Get the current focus of a player of the trial. + Return (a shallow copy of) the set of players of this trial that are regulars and + satisfy a condition if given. Parameters ---------- - user : ClientManager.Client - Player to check. - - Raises - ------ - TrialError.UserNotPlayerError - If the user is not a player of the trial. + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all regulars returned satisfy. Defaults to None (no checked + conditions). Returns ------- - float - Current focus of the player. + Set[ClientManager.Client] + The (filtered) regulars of this trial. """ - try: - return self._player_to_focus[user.id][0] - except KeyError: - raise TrialError.UserNotPlayerError + return super().get_regulars(cond=cond) - def set_focus(self, user, new_focus): + def is_leader(self, user: ClientManager.Client) -> bool: """ - Set the focus of a player of the trial. + Decide if a user is a leader of the trial. Parameters ---------- user : ClientManager.Client - Client to change. - new_focus : float - New focus. + User to test. Raises ------ TrialError.UserNotPlayerError - If the user is not a player of the trial. - TrialError.FocusIsInvalidError - If the new focus is below the trial minimum or above the trial maximum. + If the player to test is not a player of this trial. Returns ------- - None. + bool + True if the player is a user, False otherwise. """ - if user not in self.get_players(): + try: + return super().is_leader(user) + except HubbedGameError.UserNotPlayerError: raise TrialError.UserNotPlayerError - _, min_focus, max_focus = self._player_to_focus[user.id] - - if not min_focus <= new_focus <= max_focus: - raise TrialError.FocusIsInvalidError - self._player_to_focus[user.id] = (new_focus, min_focus, max_focus) - user.send_health(side=1, health=int(new_focus)) - self._check_structure() - - def change_focus_by(self, user, change_by): + def add_leader(self, user: ClientManager.Client): """ - Change the focus of a player by a certain value. If the new focus value goes - below the trial minimum, it is set to the trial minimum. If instead it goes above the - trial maximum, it is set to the trial maximum. + Set a user as leader of this trial (promote to leader). Parameters ---------- user : ClientManager.Client - Client to change. - change_by : float - Amount to change focus by. + Player to promote to leader. Raises ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. TrialError.UserNotPlayerError - If the user is not a player of the trial. - - Returns - ------- - None. + If the player to promote is not a player of this trial. + TrialError.UserAlreadyLeaderError + If the player to promote is already a leader of this trial. """ - if user not in self.get_players(): - raise TrialError.UserNotPlayerError - - new_focus = self._player_to_focus[user.id][0] + change_by - new_focus = max(self._min_focus, min(self._max_focus, new_focus)) - self.set_focus(user, new_focus) # Also calls _check_structure() + self.unchecked_add_leader(user) + self.manager._check_structure() - def get_min_focus(self, user) -> float: + def unchecked_add_leader(self, user: ClientManager.Client): """ - Get the current minimum focus of a player of the trial. + Set a user as leader of this trial (promote to leader). + + This method does not assert structural integrity. Parameters ---------- user : ClientManager.Client - Player to check. + Player to promote to leader. Raises ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. TrialError.UserNotPlayerError - If the user is not a player of the trial. - - Returns - ------- - float - Current minimum focus of the player. + If the player to promote is not a player of this trial. + TrialError.UserAlreadyLeaderError + If the player to promote is already a leader of this trial. """ try: - return self._player_to_focus[user.id][1] - except KeyError: + super().unchecked_add_leader(user) + except HubbedGameError.GameIsUnmanagedError: + raise TrialError.GameIsUnmanagedError + except HubbedGameError.UserNotPlayerError: raise TrialError.UserNotPlayerError + except HubbedGameError.UserAlreadyLeaderError: + raise TrialError.UserAlreadyLeaderError - def get_max_focus(self, user) -> float: + def remove_leader(self, user: ClientManager.Client): """ - Get the current maximum focus of a player of the trial. + Make a user no longer leader of this trial (demote). Parameters ---------- user : ClientManager.Client - Player to check. + User to demote. Raises ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. TrialError.UserNotPlayerError - If the user is not a player of the trial. - - Returns - ------- - float - Current maximum focus of the player. + If the player to demote is not a player of this trial. + TrialError.UserNotLeaderError + If the player to demote is already not a leader of this trial. """ - try: - return self._player_to_focus[user.id][2] - except KeyError: - raise TrialError.UserNotPlayerError + self.unchecked_remove_leader(user) + self.manager._check_structure() - def new_nsd(self, creator=None, player_limit=None, add_players=False, - require_invitations=False, require_players=True, - require_character=False, team_limit=None, timer_limit=None, - autoadd_on_trial_player_add=None, - timer_start_value=300) -> NonStopDebate: + def unchecked_remove_leader(self, user: ClientManager.Client): """ - Create a new NSD managed by this trial. Overriden default parameters include: - * An NSD does not require leaders. + Make a user no longer leader of this trial (demote). + + This method does not assert structural integrity. Parameters ---------- - creator : ClientManager.Client, optional - The player who created this NSD. If set, they will also be added to the NSD. - Defaults to None. - player_limit : int or None, optional - If an int, it is the maximum number of players the NSD supports. If None, it - indicates the NSD has no player limit. Defaults to None. - require_invitations : bool, optional - If True, users can only be added to the NSD if they were previously invited. If - False, no checking for invitations is performed. Defaults to False. - require_players : bool, optional - If True, if at any point the NSD loses all its players, the NSD will automatically - be deleted. If False, no such automatic deletion will happen. Defaults to True. - require_character : bool, optional - If False, players without a character will not be allowed to join the NSD, and - players that switch to something other than a character will be automatically - removed from the NSD. If False, no such checks are made. A player without a - character is considered one where player.has_character() returns False. Defaults - to False. - team_limit : int or None, optional - If an int, it is the maximum number of teams the NSD will support. If None, it - indicates the NSD will have no team limit. Defaults to None. - autoadd_on_trial_player_add : bool, optional - If True, players that are added to the trial will be automatically added if permitted - by the conditions of the game. If False, no such adding will take place. Defaults to - None (use self.autoadd_minigame_on_player_added). - timer_limit : int or None, optional - If an int, it is the maximum number of timers the NSD will support. If None, it - indicates the NSD will have no timer limit. Defaults to None. - timer_start_value : float, optional - In seconds, the length of time the main timer of this non-stop debate will have at the - start. It must be a positive number. Defaults to 300 (5 minutes). - - Returns - ------- - NonStopDebate - The created NSD. + user : ClientManager.Client + User to demote. Raises ------ - GameError.ManagerTooManyGamesError - If the manager is already managing its maximum number of minigames. - Any error from the created NSD's add_player(creator) - If the NSD cannot add `creator` to the NSD if given one. + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.UserNotPlayerError + If the player to demote is not a player of this trial. + TrialError.UserNotLeaderError + If the player to demote is already not a leader of this trial. """ - if autoadd_on_trial_player_add is None: - autoadd_on_trial_player_add = self.get_autoadd_minigame_on_player_added() + if self.is_unmanaged(): + raise TrialError.GameIsUnmanagedError - areas = {creator.area} if creator else set() - nsd_factory = functools.partial( - NonStopDebate, - trial=self, - autoadd_on_trial_player_add=autoadd_on_trial_player_add, - timer_start_value=timer_start_value) - - nsd = self._minigame_manager.new_game(game_type=nsd_factory, - player_limit=player_limit, - player_concurrent_limit=1, - require_invitations=require_invitations, - require_players=require_players, - require_leaders=False, - require_character=require_character, - team_limit=team_limit, timer_limit=timer_limit, - areas=areas, - autoadd_on_client_enter=False) - nsd.setup_timers() - # Add creator manually. This is because otherwise the creator does not get access to - # the timer info. try: - if creator: - nsd.add_player(creator) - except GameError as ex: - # Discard game - self._minigame_manager.delete_game(nsd) - raise ex - - if add_players: - clients_to_add = {client for area in areas for client in area.clients} - if creator: - clients_to_add.discard(creator) - for client in clients_to_add: - try: - nsd.add_player(client) - except GameError.UserNotPlayerError: - continue + super().unchecked_remove_leader(user) + except HubbedGameError.GameIsUnmanagedError: + raise TrialError.GameIsUnmanagedError + except HubbedGameError.UserNotPlayerError: + raise TrialError.UserNotPlayerError + except HubbedGameError.UserNotLeaderError: + raise TrialError.UserNotLeaderError - # Manually give packets to nonplayers - for nonplayer in nsd.get_nonplayer_users_in_areas(): - nsd.introduce_user(nonplayer) + def has_ever_had_players(self) -> bool: + """ + Return True if a player has ever been added to this trial, False otherwise. - return nsd + Returns + ------- + bool + True if the trial has ever had a player added, False otherwise. - def get_nsd_of_user(self, user) -> NonStopDebate: """ - Get the NSD the user is in. - Parameters - ---------- - user : ClientManager.Client - User to check. + return super().has_ever_had_players() - Raises - ------ - TrialError.UserNotInMinigameError - If the user is not in an NSD managed by this trial. + def requires_leaders(self) -> bool: + """ + Return whether the trial requires leaders at all times. Returns ------- - NonStopDebate - NSD of the user. - + bool + Whether the trial requires leaders at all times. """ - games = self._minigame_manager.get_games_of_user(user) - nsds = {game for game in games if isinstance(game, NonStopDebate)} - if not nsds: - raise TrialError.UserNotInMinigameError - if len(nsds) > 1: - raise RuntimeError(nsds) - return next(iter(nsds)) + return super().requires_leaders() - def get_minigames(self): + def requires_participant_characters(self) -> bool: """ - Return the minigames of this trial. + Return whether the trial requires players have a participant character at all times. Returns ------- - set of TrialMinigame - Trial minigames of this trial. - + bool + Whether the trial requires players have a participant character at all times. """ - return self._minigame_manager.get_games().copy() + return super().requires_participant_characters() - def get_minigame_by_id(self, minigame_id) -> TrialMinigame: + def new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: """ - If `minigame_id` is the ID of a minigame managed by this trial, return that. + Create a new timer managed by this trial with given parameters. Parameters ---------- - minigame_id : str - ID of the minigame this trial manages. + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the trial will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. Returns ------- - TrialMinigame - The minigame with that ID. + Timer + The created timer. Raises ------ - GameError.ManagerInvalidGameIDError - If `minigame_id` is not the ID of a minigame this game manages. + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameTooManyTimersError + If the trial is already managing its maximum number of timers. + + """ + + timer = self.unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + self.manager._check_structure() + return timer + + def unchecked_new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this trial with given parameters. + + This method does not assert structural integrity. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the trial will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameTooManyTimersError + If the trial is already managing its maximum number of timers. """ - return self._minigame_manager.get_game_by_id(minigame_id) + try: + return super().unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + except HubbedGameError.GameIsUnmanagedError: + raise TrialError.GameIsUnmanagedError + except HubbedGameError.GameTooManyTimersError: + raise TrialError.GameTooManyTimersError - def get_nsd_by_id(self, nsd_id) -> NonStopDebate: + def delete_timer(self, timer: Timer) -> str: """ - If `nsd_id` is the ID of a nonstop debate managed by this trial, return that. + Delete a timer managed by this trial, terminating it first if needed. Parameters ---------- - nsd_id : str - ID of the nonstop debate this trial manages. + timer : Timer + The timer to delete. Returns ------- - NonStopDebate - The nonstop debate with that ID. + str + The ID of the timer that was deleted. Raises ------ - GameError.ManagerInvalidGameIDError - If `nsd_id` is not the ID of a nonstop debate this game manages. + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameDoesNotManageTimerError + If the trial does not manage the target timer. """ - minigame = self.get_minigame_by_id(nsd_id) - minigame_type = minigame.get_type() - if minigame_type != TRIALMINIGAMES.NONSTOP_DEBATE: - raise GameError.ManagerInvalidGameIDError(f'`{nsd_id}` is a minigame of type ' - '{minigame_type}, not nonstop debate.') - return minigame + timer_id = self.unchecked_delete_timer(timer) + self.manager._check_structure() + return timer_id - def get_available_minigame_id(self) -> str: + def unchecked_delete_timer(self, timer: Timer) -> str: """ - Get a minigame ID that no other minigame managed by this manager has. + Delete a timer managed by this trial, terminating it first if needed. + + This method does not assert structural integrity. + + Parameters + ---------- + timer : Timer + The timer to delete. Returns ------- str - A unique minigame ID. + The ID of the timer that was deleted. Raises ------ - GameError.ManagerTooManyGamesError - If the manager is already managing its maximum number of games. + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameDoesNotManageTimerError + If the trial does not manage the target timer. """ - game_number = 0 - game_limit = self._minigame_manager.get_game_limit() - while game_limit is None or game_number < game_limit: - new_game_id = "{}g{}".format(self.get_id(), game_number) - if new_game_id not in self._minigame_manager.get_game_ids(): - return new_game_id - game_number += 1 - raise GameError.ManagerTooManyGamesError + try: + return super().unchecked_delete_timer(timer) + except HubbedGameError.GameIsUnmanagedError: + raise TrialError.GameIsUnmanagedError + except HubbedGameError.GameDoesNotManageTimerError: + raise TrialError.GameDoesNotManageTimerError - def get_info(self, include_health=True) -> str: + def get_timers(self) -> Set[Timer]: """ - Obtain a long description of the trial and its players. + Return (a shallow copy of) the timers this trial manages. + + Returns + ------- + Set[Timer] + Timers this trial manages. + + """ + + return super().get_timers() + + def get_timer_by_id(self, timer_id: str) -> Timer: + """ + If `timer_tag` is the ID of a timer managed by this trial, return that timer. Parameters ---------- - include_health : bool - If True, the description will include the influence and focus values of the trial - players; if False, these values will be omitted. Defaults to True. + timer_id: str + ID of timer this trial manages. Returns ------- - str - Description. + Timer + The timer whose ID matches the given ID. + + Raises + ------ + TrialError.GameInvalidTimerIDError: + If `timer_tag` is a str and it is not the ID of a timer this trial manages. """ - tid = self.get_id() - leaders = self.get_leaders() - regulars = self.get_regulars() + try: + return super().get_timer_by_id(timer_id) + except HubbedGameError.GameInvalidTimerIDError: + raise TrialError.GameInvalidTimerIDError - num_members = len(leaders.union(regulars)) - group_texts = list() - for group in (leaders, regulars): - if not group: - group_texts.append('\n*None') - continue - group_text = '' - for player in sorted(group, key=lambda c: c.displayname): - player_text = f'[{player.id}] {player.displayname}' - if include_health: - player_text += ': ' - influence = self.get_influence(player) - focus = self.get_focus(player) - player_text += f'Influence: {influence}; ' - player_text += f'Focus: {focus}' - group_text += f'\n*{player_text}' - group_texts.append(group_text) + def get_timer_limit(self) -> Union[int, None]: + """ + Return the timer limit of this trial. - leader_text, regular_text = group_texts - area_ids = ', '.join(sorted({str(area.id) for area in self.get_areas()})) - info = (f'Trial {tid} [{num_members}/-] ({area_ids}).' - f'\nLeaders: {leader_text}' - f'\nRegular members: {regular_text}') - return info + Returns + ------- + Union[int, None] + Timer limit. - def destroy(self): """ - Mark this game as destroyed and notify its manager so that it is deleted. - If the game is already destroyed, this function does nothing. - This method is reentrant (it will do nothing though). + return super().get_timer_limit() + + def get_timer_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all timers managed by this trial. Returns ------- - None. + Set[str] + The IDs of all managed timers. """ - # Store for later - users = self.get_users_in_areas() + return super().get_timer_ids() - # Remove minigames first. This is done first so as to enforce explicit destruction - # (rather than rely on other methods). - for game in self._minigame_manager.get_games(): - game.destroy() + def new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this trial. - super().destroy() # Also calls _check_structure() + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this trial. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. - # Force every user in the former areas of the trial to be dismissed - for user in users: - self.dismiss_user(user) + Returns + ------- + _Team + The created team. - def end(self): - """ - Destroy the trial and play the trial end splash animation to all users in the trial areas. + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameTooManyTeamsError + If the trial is already managing its maximum number of teams. + TrialError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this trial. + + """ + + team = self.unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + self.manager._check_structure() + return team + + def unchecked_new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this trial. + + This method does not assert structural integrity. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this trial. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. Returns ------- - None. + _Team + The created team. + + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameTooManyTeamsError + If the trial is already managing its maximum number of teams. + TrialError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this trial. """ - users = self.get_users_in_areas() # Store for later + try: + return super().unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + except HubbedGameError.GameIsUnmanagedError: + raise TrialError.GameIsUnmanagedError + except HubbedGameError.GameTooManyTeamsError: + raise TrialError.GameTooManyTeamsError + except HubbedGameError.UserInAnotherTeamError: + raise TrialError.UserInAnotherTeamError - self.destroy() + def delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this trial. - for user in users: - user.send_splash(name='testimony2') + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameDoesNotManageTeamError + If the trial does not manage the target team. - def _on_area_client_left_final(self, area, client=None, old_displayname=None, - ignore_bleeding=False, ignore_autopass=False): """ - If a player left to an area not part of the trial, remove the player and warn them and - the leaders of the trial. - If a non-plyer left to an area not part of the trial, warn them and the leaders of the - trial. + team_id, players = self.unchecked_delete_team(team) + self.manager._check_structure() + return team_id, players + + def unchecked_delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this trial. + + This method does not assert structural integrity. Parameters ---------- - area : AreaManager.Area - Area that signaled a client has left. - client : ClientManager.Client, optional - The client that has left. The default is None. - old_displayname : str, optional - The old displayed name of the client before they changed area. This will typically - change only if the client's character or showname are taken. The default is None. - ignore_bleeding : bool, optional - If the code should ignore actions regarding bleeding. The default is False. - ignore_autopass : bool, optional - If the code should ignore actions regarding autopass. The default is False. + team : _Team + The team to delete. Returns ------- - None. + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.GameDoesNotManageTeamError + If the trial does not manage the target team. """ - if client.area in self.get_areas(): - return + try: + return super().unchecked_delete_team(team) + except HubbedGameError.GameIsUnmanagedError: + raise TrialError.GameIsUnmanagedError + except HubbedGameError.GameDoesNotManageTeamError: + raise TrialError.GameDoesNotManageTeamError - if client in self.get_players(): - client.send_ooc(f'You have left to an area not part of trial `{self.get_id()}` and ' - f'thus were automatically removed from the trial.') - client.send_ooc_others(f'(X) Player {old_displayname} [{client.id}] has left to ' - f'an area not part of your trial and thus was automatically ' - f'removed it ({area.id}->{client.area.id}).', - pred=lambda c: c in self.get_leaders()) + def manages_team(self, team: _Team) -> bool: + """ + Return True if the team is managed by this trial, False otherwise. - nonplayers = self.get_nonplayer_users_in_areas() - tid = self.get_id() + Parameters + ---------- + team : _Team + The team to check. - self.remove_player(client) + Returns + ------- + bool + True if the trial manages this team, False otherwise. - if self.is_unmanaged(): - client.send_ooc(f'Your trial `{tid}` was automatically ' - f'ended as it lost all its players.') - client.send_ooc_others(f'(X) Trial `{tid}` was automatically ' - f'ended as it lost all its players.', - is_zstaff_flex=True, not_to=nonplayers) - client.send_ooc_others('The trial you were watching was automatically ended ' - 'as it lost all its players.', - is_zstaff_flex=False, part_of=nonplayers) - else: - client.send_ooc(f'You have left to an area not part of trial `{self.get_id()}`.') - client.send_ooc_others(f'(X) Player {old_displayname} [{client.id}] has left to ' - f'an area not part of your trial ({area.id}->{client.area.id}).', - pred=lambda c: c in self.get_leaders()) - self.dismiss_user(client) + """ - self._check_structure() + return super().manages_team(team) - def _on_area_client_entered_final(self, area, client=None, old_area=None, old_displayname=None, - ignore_bleeding=False, ignore_autopass=False): + def get_teams(self) -> Set[_Team]: """ - If a non-player entered, warn them and the leaders of the trial. + Return (a shallow copy of) the teams this trial manages. + + Returns + ------- + Set[_Team] + Teams this trial manages. + + """ + + return super().get_teams() + + def get_team_by_id(self, team_id: str) -> _Team: + """ + If `team_id` is the ID of a team managed by this trial, return the team. Parameters ---------- - area : AreaManager.Area - Area that signaled a client has entered. - client : ClientManager.Client, optional - The client that has entered. The default is None. - old_area : AreaManager.Area - The old area the client has come from. The default is None. - old_displayname : str, optional - The old displayed name of the client before they changed area. This will typically - change only if the client's character or showname are taken. The default is None. - ignore_bleeding : bool, optional - If the code should ignore actions regarding bleeding. The default is False. - ignore_autopass : bool, optional - If the code should ignore actions regarding autopass. The default is False. + team_id : str + ID of the team this trial manages. Returns ------- - None. + _Team + The team that matches the given ID. + + Raises + ------ + TrialError.GameInvalidTeamIDError: + If `team_id` is not the ID of a team this trial manages. """ - if client not in self.get_players() and old_area not in self.get_areas(): - client.send_ooc(f'You have entered an area part of trial `{self.get_id()}`.') + try: + return super().get_team_by_id(team_id) + except HubbedGameError.GameInvalidTeamIDError: + raise TrialError.GameInvalidTeamIDError + + def get_team_limit(self) -> Union[int, None]: + """ + Return the team limit of this trial. + + Returns + ------- + Union[int, None] + Team limit. + + """ + + return super().get_team_limit() + + def get_team_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all teams managed by this trial. + + Returns + ------- + Set[str] + The IDs of all managed teams. + + """ + + return super().get_team_ids() + + def get_teams_of_user(self, user: ClientManager.Client) -> Set[_Team]: + """ + Return (a shallow copy of) the teams managed by this trial user `user` is a player + of. If the user is part of no such team, an empty set is returned. + + Parameters + ---------- + user : ClientManager.Client + User whose teams will be returned. + + Returns + ------- + Set[_Team] + Teams the player belongs to. + + """ + + return super().get_teams_of_user(user) + + def get_users_in_some_team(self): + """ + Return (a shallow copy of) all the users that are part of some team managed by this + trial. + + Returns + ------- + Set[ClientManager.Client] + Users in some managed team. + + """ + + return super().get_users_in_some_team() + + def get_available_team_id(self) -> str: + """ + Get a team ID that no other team managed by this team has. + + Returns + ------- + str + A unique team ID. + + Raises + ------ + TrialError.GameTooManyTeamsError + If the trial is already managing its maximum number of teams. + + """ + + try: + return super().get_available_team_id() + except HubbedGameError.GameTooManyTeamsError: + raise TrialError.GameTooManyTeamsError + + def get_autoadd_on_client_enter(self) -> bool: + """ + Return True if the trial will always attempt to add nonplayer users who enter an + area part of the trial, False otherwise. + + Returns + ------- + bool + True if the trial will always attempt to add nonplayer users who enter an area + part of the trial, False otherwise. + """ + + return super().get_autoadd_on_client_enter() + + def set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + Parameters + ---------- + new_value : bool + New value. + + """ + + self.unchecked_set_autoadd_on_client_enter(new_value) + self.manager._check_structure() + + def unchecked_set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + This method does not assert structural integrity. + + Parameters + ---------- + new_value : bool + New value. + + """ + + super().unchecked_set_autoadd_on_client_enter(new_value) + + def add_area(self, area: AreaManager.Area): + """ + Add an area to this trial's set of areas. + + Parameters + ---------- + area : AreaManager.Area + Area to add. + + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.AreaDisallowsBulletsError + If the area to add disallows bullets. + TrialError.AreaAlreadyInGameError + If the area is already part of the trial. + TrialError.AreaHitGameConcurrentLimitError. + If `area` has reached the concurrent area membership limit of any of the games with + areas it belongs to managed by this manager, or by virtue of adding this area it will + violate this trial's concurrent area membership limit. + + """ + + self.unchecked_add_area(area) + self.manager._check_structure() + + def remove_area(self, area: AreaManager.Area): + """ + Remove an area from this trial's set of areas. + If the area is already a part of the trial, do nothing. + If any player of the trial is in this area, they are removed from the + trial. + If the trial has no areas remaining, it will be automatically destroyed. + + Parameters + ---------- + area : AreaManager.Area + Area to remove. + + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.AreaNotInGameError + If the area is already not part of the trial. + + """ + + self.unchecked_remove_area(area) + self.manager._check_structure() + + def unchecked_remove_area(self, area: AreaManager.Area): + """ + Remove an area from this trial's set of areas. + If the area is already a part of the trial, do nothing. + If any player of the trial is in this area, they are removed from the + trial. + If the trial has no areas remaining, it will be automatically destroyed. + + This method does not assert structural integrity. + + Parameters + ---------- + area : AreaManager.Area + Area to remove. + + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.AreaNotInGameError + If the area is already not part of the trial. + + """ + + try: + super().unchecked_remove_area(area) + except HubbedGameError.GameIsUnmanagedError: + raise TrialError.GameIsUnmanagedError + except HubbedGameError.AreaNotInGameError: + raise TrialError.AreaNotInGameError + + def requires_areas(self) -> bool: + """ + Return whether the trial requires areas at all times. + + Returns + ------- + bool + Whether the trial requires areas at all times. + """ + + return super().requires_areas() + + def has_area(self, area: AreaManager.Area) -> bool: + """ + If the area is part of this trial's set of areas, return True; otherwise, return + False. + + Parameters + ---------- + area : AreaManager.Area + Area to check. + + Returns + ------- + bool + True if the area is part of the trial's set of areas, False otherwise. + + """ + + return super().has_area(area) + + def get_areas(self) -> Set[AreaManager.Area]: + """ + Return (a shallow copy of) the set of areas of this trial. + + Returns + ------- + Set[AreaManager.Area] + Set of areas of the trial. + + """ + + return super().get_areas() + + def get_area_concurrent_limit(self) -> Union[int, None]: + """ + Return the concurrent area membership limit of this trial. + + Returns + ------- + Union[int, None] + The concurrent area membership limit. + + """ + + return super().get_area_concurrent_limit() + + def get_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the trial, even those that are not players of + the trial. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the trial. + + """ + + return super().get_users_in_areas() + + def get_nonleader_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the trial, even those that are not players of + the trial, such that they are not leaders of the trial. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the trial that are not leaders of the trial. + + """ + + return super().get_nonleader_users_in_areas() + + def get_nonplayer_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the trial that are not players of the + trial. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the trial that are not players of the trial. + + """ + + return super().get_nonplayer_users_in_areas() + + def is_unmanaged(self): + """ + Return True if this trial is unmanaged, False otherwise. + + Returns + ------- + bool + True if unmanaged, False otherwise. + + """ + + return super().is_unmanaged() + + def destroy(self): + """ + Mark this trial as destroyed and notify its manager so that it is deleted. + If the trial is already destroyed, this function does nothing. + A trial marked for destruction will delete all of its timers, teams, remove all + its players and unsubscribe it from updates of its former players. + + This method is reentrant (it will do nothing though). + + Returns + ------- + None. + + """ + + self.unchecked_destroy() + self.manager._check_structure() + self._check_structure() # Manager will not check this otherwise. + + def _on_client_inbound_ms_check( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): + """ + Default callback for trial player signaling it wants to check if sending an IC + message is appropriate. The IC arguments can be passed by reference, so this also serves as + an opportunity to modify the IC message if neeeded. + + To indicate a message should not be sent, some TsuserverException can be raised. The + message of the exception will be sent to the client. + + Parameters + ---------- + player : ClientManager.Client + Player that wants to send the IC message. + contents : Dict[str, Any], optional + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_client_inbound_ms_check(player, contents=contents) + + def _on_client_inbound_ms_final( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): + """ + Default callback for trial player signaling it has sent an IC message. + This callback is executed after the server is done making all modifications to the MS packet + sent by the server. + + By default does nothing. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has sent an IC message. + contents : Dict[str, Any], optional + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_client_inbound_ms_final(player, contents=contents) + + def _on_area_client_inbound_ms_check( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + contents: Dict[str, Any] = None + ): + """ + Default callback for trial area signaling a client in the area sent an IC message. + Unlike the ClientManager.Client callback for send_ic_check, this one is triggered + regardless of whether the sender is part of the trial or not. This is useful for + example, to filter out messages sent by non-players. + + By default does nothing. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has entered. + client : ClientManager.Client, optional + The client that has send the IC message. The default is None. + contents : Dict[str, Any] + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_area_client_inbound_ms_check(area, client=client, contents=contents) + + def _on_area_destroyed(self, area: AreaManager.Area): + """ + Default callback for trial area signaling it was destroyed. + + By default it calls self.unchecked_remove_area(area). + + Parameters + ---------- + area : AreaManager.Area + Area that signaled it was destroyed. + + Returns + ------- + None. + + """ + + super()._on_area_destroyed(area) + +class _Trial(_TrialTrivialInherited): + """ + A trial is a hubbed game that can manage 'trial minigames', which are the following + trial games (server.trialminigame): + * Nonstop Debates (server.nonstopdebate). + + While multiple minigames may be going on at the same time, no player may be part of two + minigames simultaneously. + + Attributes + ---------- + server : TsuserverDR + Server the trial belongs to. + manager : TrialManager + Manager for this trial. + hub: _Hub + Hub for this hubbed game. + listener : Listener + Standard listener of the trial. + + Callback Methods + ---------------- + _on_area_client_left_final + Method to perform once a client left an area of the trial. + _on_area_client_entered_final + Method to perform once a client entered an area of the trial. + _on_area_destroyed + Method to perform once an area of the trial is marked for destruction. + _on_client_inbound_ms_check + Method to perform once a player of the trial wants to send an IC message. + _on_client_inbound_ms_final + Method to perform once a player of the trial sends an IC message. + _on_client_change_character + Method to perform once a player of the trial has changed character. + _on_client_destroyed + Method to perform once a player of the trial is destroyed. + + """ + + # (Private) Attributes + # -------------------- + # _player_to_influence : Dict[ClientManager.Client, float] + # Mapping of trial players to their current influence. + # _player_to_focus : Dict[ClientManager.Client, float] + # Mapping of trial players to their current focus. + # _min_influence : int + # Minimum influence any player of the trial may have. + # _max_influence : int + # Maximum influence any player of the trial may have. + # _min_focus : int + # Minimum influence any player of the trial may have. + # _max_focus : int + # Maximum influence any player of the trial may have. + # _autoadd_minigame_on_player_added : bool + # Whether to automatically add new players of the trial to any active minigames of the trial. + # _client_timer_id : int + # ID of the client timer to use for trial purposes. + # _minigame_manager : GameManager + # Manager for all games of the trial. + + # Invariants + # ---------- + # 1. For each player of a minigame of this trial, they are also a player of the trial. + # 2. For each area of a minigame of this trial, they are also an area of the trial. + # 3. The player to influence and player to focus maps contain exactly the IDs of all players + # of the trial. + # 4. For each influence and focus value in the player to influence and player to focus maps, + # they are a value between 0 and 10 inclusive. + # 5. The invariants from the parent class GameWithArea are satisfied. + + + def __init__( + self, + server: TsuserverDR, + manager: TrialManager, + trial_id: str, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + area_concurrent_limit: Union[int, None] = None, + autoadd_on_client_enter: bool = False, + require_areas: bool = True, + hub: _Hub = None, + # new + autoadd_minigame_on_player_added: bool = False, + minigame_limit: int = 1, + ): + """ + Create a new trial. A trial should not be fully initialized anywhere else other than + some manager code, as otherwise the manager will not recognize the trial. + + Parameters + ---------- + server : TsuserverDR + Server the trial belongs to. + hub : _Hub + Hub the trial belongs to. + manager : TrialManager + Manager for this trial. + trial_id : str + Identifier of the trial. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the trial supports. If None, it + indicates the trial has no player limit. Defaults to None. + player_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of trials managed by `manager` that any + player of this trial may belong to, including this trial. If None, it indicates + that this trial does not care about how many other trials managed by `manager` each + of its players belongs to. Defaults to None. + require_invitation : bool, optional + If True, players can only be added to the trial if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the trial has no players left, the trial will + automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + require_leaders : bool, optional + If True, if at any point the trial has no leaders left, the trial will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + trial, and players that switch to something other than a participant character + will be automatically removed from the trial. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the trial supports. If None, it + indicates the trial has no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the trial supports. If None, it + indicates the trial has no timer limit. Defaults to None. + area_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of trials managed by `manager` that any + area of this trial may belong to, including this trial. If None, it indicates + that this trial does not care about how many other trials managed by + `manager` each of its areas belongs to. Defaults to 1 (an area may not be a part of + another trial managed by `manager` while being an area of this trial). + autoadd_on_client_enter : bool, optional + If True, nonplayer users that enter an area part of the trial will be automatically + added if permitted by the conditions of the trial. If False, no such adding will take + place. Defaults to False. + require_areas : bool, optional + If True, if at any point the trial has no areas left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + hub : _Hub, optional + Hub the hubbed game belongs to. Defaults to None. + autoadd_minigame_on_player_added: bool, optional + If True, any player added to the trial will be automatically added as a player of the + latest minigame currently open in the trial. If no such minigame is open or the + player addition fails, no action is taken. If False, no such adding will take place. + Defaults to False. + minigame_limit : Union[int, None], optional + If an int, it is the maximum number of minigames the trial may have simultaneously. + If None, it indicates the trial has no minigame limit. Defaults to 1. + + """ + + self._player_to_influence: Dict[ClientManager.Client, float] = dict() + self._player_to_focus: Dict[ClientManager.Client, float] = dict() + self._min_influence = 0 + self._max_influence = 10 + self._min_focus = 0 + self._max_focus = 10 + + self._client_timer_id = 0 + self._autoadd_minigame_on_player_added = autoadd_minigame_on_player_added + + self._minigame_manager = HubbedGameManager( + server, + managee_limit=minigame_limit, + default_managee_type=_TrialMinigame, + ) + super().__init__( + server, + manager, + trial_id, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + require_areas=require_areas, + hub=hub, + ) + + self.listener.update_events({ + 'client_inbound_rt': self._on_client_inbound_rt, + }) + self.manager: TrialManager # Setting for typing + + def get_type_name(self) -> str: + """ + Return the type name of the trial. Names are fully lowercase. + Implementations of the class should replace this with a human readable name of the trial. + + Returns + ------- + str + Type name of the trial. + + """ + + return "trial" + + def get_autoadd_minigame_on_player_added(self) -> bool: + """ + Get the default behavior to do when a player is added to a trial: + * If True, the trial will automatically try to add any players that are added to the + trial to all minigames it hosts in some arbitrary order. + * If False, no such automatic attempts are done. + + Returns + ------- + bool + True if an attempt will be made, False otherwise. + + """ + + return self._autoadd_minigame_on_player_added + + def set_autoadd_minigame_on_player_added(self, new_value: bool): + """ + Set the default behavior to do when a player is added to a trial: + * If True, the trial will automatically try to add any players that are added to the + trial to all minigames it hosts in some arbitrary order. + * If False, no such automatic attempts are done. + + Parameters + ---------- + new_value : bool + New value. + + """ + + self.unchecked_set_autoadd_minigame_on_player_added(new_value) + self.manager._check_structure() + + def unchecked_set_autoadd_minigame_on_player_added(self, new_value: bool): + """ + Set the default behavior to do when a player is added to a trial: + * If True, the trial will automatically try to add any players that are added to the + trial to all minigames it hosts in some arbitrary order. + * If False, no such automatic attempts are done. + + This method does not assert structural integrity. + + Parameters + ---------- + new_value : bool + New value. + + """ + + self._autoadd_minigame_on_player_added = new_value + + def unchecked_add_player(self, user: ClientManager.Client): + """ + Make a user a player of the trial. By default this player will not be a leader, + unless the trial has no leaders and it requires a leader. + It will also subscribe the trial to the player so it can listen to its updates. + + Newly added players will be ordered to switch to a 'trial' variant. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to add to the trial. They must be in an area part of the trial. + + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.UserNotInAreaError + If the user is not in an area part of the trial. + TrialError.UserHasNoCharacterError + If the user has no character but the trial requires that all players have + characters. + TrialError.UserNotInvitedError + If the trial requires players be invited to be added and the user is not + invited. + TrialError.UserAlreadyPlayerError + If the user to add is already a user of the trial. + TrialError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the trials + managed by the manager of this trial, or by virtue of joining this + trial they would violate this trial's concurrent player membership + limit. + TrialError.GameIsFullError + If the trial reached its player limit. + + """ + + try: + super().unchecked_add_player(user) + except HubbedGameError.GameIsUnmanagedError: + raise TrialError.GameIsUnmanagedError + except HubbedGameError.UserNotInAreaError: + raise TrialError.UserNotInAreaError + except HubbedGameError.UserHasNoCharacterError: + raise TrialError.UserHasNoCharacterError + except HubbedGameError.UserNotInvitedError: + raise TrialError.UserNotInvitedError + except HubbedGameError.UserAlreadyPlayerError: + raise TrialError.UserAlreadyPlayerError + except HubbedGameError.UserHitGameConcurrentLimitError: + raise TrialError.UserHitGameConcurrentLimitError + except HubbedGameError.GameIsFullError: + raise TrialError.GameIsFullError + + self._player_to_influence[user.id] = (self._max_influence, self._min_influence, + self._max_influence) + self._player_to_focus[user.id] = (self._max_focus, self._min_focus, self._max_focus) + + self.introduce_user(user) + self.publisher.publish('trial_player_added', { + 'player': user, + }) + + def unchecked_remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this trial. If they were part of a team + managed by this trial, they will also be removed from said team. It will also + unsubscribe the trial from the player so it will no longer listen to its updates. + + If the trial required that there it always had players and by calling this method + the trial had no more players, the trial will automatically be scheduled for + deletion. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.UserNotPlayerError + If the user to remove is already not a player of this trial. + + """ + + if self.is_unmanaged(): + raise TrialError.GameIsUnmanagedError + if not self.is_player(user): + raise TrialError.UserNotPlayerError + + self._player_to_influence.pop(user.id) + self._player_to_focus.pop(user.id) + for game in self._minigame_manager.get_managees(): + if user in game.get_players(): + game.remove_player(user) + + try: + super().unchecked_remove_player(user) + except HubbedGameError.GameIsUnmanagedError: + # Should not have made it here as we already asserted the trial is not unmanaged + raise RuntimeError(self, user) + except HubbedGameError.UserNotPlayerError: + # Should not have made it here as we already asserted the user is a player of the trial + raise RuntimeError(self, user) + + def introduce_user(self, user: ClientManager.Client): + """ + Broadcast information relevant for a user entering an area of the trial, namely current + gamemode if needed. + Note the user needs not be in the same area as the trial, nor be a player of the trial. + + Parameters + ---------- + user : ClientManager.Client + User to introduce. + + """ + + if self.is_player(user): + user.send_health(side=1, health=int(self._player_to_focus[user.id][0])) + user.send_health(side=2, health=int(self._player_to_influence[user.id][0])) + + # If there are any minigames, let them set the splashes, gamemode and timers + if self.get_minigames(): + return + + user.send_gamemode(name='trial') + user.send_splash(name='testimony1') + + user.send_timer_pause(timer_id=self._client_timer_id) + user.send_timer_set_time(timer_id=self._client_timer_id, new_time=0) + user.send_timer_set_step_length(timer_id=self._client_timer_id, + new_step_length=0) + user.send_timer_set_firing_interval(timer_id=self._client_timer_id, + new_firing_interval=0) + + def dismiss_user(self, user: ClientManager.Client): + """ + Broadcast information relevant for a user that has left the trial, namely clear out + gamemode and health bars. Gamemode is only cleared if the user's new area is not part + of the trial's areas. + Note the user needs not be in the same area as the NSD, nor be a player of the NSD. + If the trial has never had any players, this method does nothing. + + Parameters + ---------- + user : ClientManager.Client + User to dismiss. + + """ + + if not self.has_ever_had_players(): + return + + # We use .new_area rather than .area as this function may have been called as a result + # of the user moving, in which case .area still points to the user's old area. + + user.send_health(side=1, health=user.area.hp_pro) + user.send_health(side=2, health=user.area.hp_def) + + # If the user is no longer in an area part of an area of the trial, clear out gamemode + if user.new_area not in self.get_areas(): + user.send_gamemode(name='') + + def unchecked_add_area(self, area: AreaManager.Area): + """ + Add an area to this trial's set of areas. + + This method does not assert structural integrity. + + Parameters + ---------- + area : AreaManager.Area + Area to add. + + Raises + ------ + TrialError.GameIsUnmanagedError + If the trial was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialError.AreaDisallowsBulletsError + If the area to add disallows bullets. + TrialError.AreaAlreadyInGameError + If the area is already part of the trial. + TrialError.AreaHitGameConcurrentLimitError. + If `area` has reached the concurrent area membership limit of any of the games with + areas it belongs to managed by this manager, or by virtue of adding this area it will + violate this trial's concurrent area membership limit. + + """ + + if self.is_unmanaged(): + raise TrialError.GameIsUnmanagedError + if not area.bullet: + raise TrialError.AreaDisallowsBulletsError + + try: + super().unchecked_add_area(area) + except HubbedGameError.GameIsUnmanagedError: + raise RuntimeError(self) + except HubbedGameError.AreaAlreadyInGameError: + raise TrialError.AreaAlreadyInGameError + except HubbedGameError.AreaHitGameConcurrentLimitError: + raise TrialError.AreaHitGameConcurrentLimitError + + def get_influence(self, user: ClientManager.Client) -> float: + """ + Get the current influence of a player of the trial. + + Parameters + ---------- + user : ClientManager.Client + Player to check. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + + Returns + ------- + float + Current influence of the player. + + """ + + try: + return self._player_to_influence[user.id][0] + except KeyError: + raise TrialError.UserNotPlayerError + + def set_influence(self, user: ClientManager.Client, new_influence: float): + """ + Set the influence of a player of the trial. + + Parameters + ---------- + user : ClientManager.Client + Client to change. + new_influence : float + New influence. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + TrialError.InfluenceIsInvalidError + If the new influence is below the trial minimum or above the trial maximum. + + """ + + self.unchecked_set_influence(user, new_influence) + self.manager._check_structure() + + def unchecked_set_influence(self, user: ClientManager.Client, new_influence: float): + """ + Set the influence of a player of the trial. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + Client to change. + new_influence : float + New influence. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + TrialError.InfluenceIsInvalidError + If the new influence is below the trial minimum or above the trial maximum. + + """ + + + if user not in self.get_players(): + raise TrialError.UserNotPlayerError + _, min_influence, max_influence = self._player_to_influence[user.id] + + if not min_influence <= new_influence <= max_influence: + raise TrialError.InfluenceIsInvalidError + + self._player_to_influence[user.id] = (new_influence, min_influence, max_influence) + user.send_health(side=2, health=int(new_influence)) + + # If the new influence is 0, warn all trial leaders + if new_influence == 0: + user.send_ooc('You ran out of influence!') + user.send_ooc_others(f'(X) {user.displayname} ran out of influence!', + pred=lambda c: c in self.get_leaders()) + + def change_influence_by(self, user: ClientManager.Client, change_by: float): + """ + Change the influence of a player by a certain value. If the new influence value goes + below the trial minimum, it is set to the trial minimum. If instead it goes above the + trial maximum, it is set to the trial maximum. + + Parameters + ---------- + user : ClientManager.Client + Client to change. + change_by : float + Amount to change influence by. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + + """ + + self.unchecked_change_influence_by(user, change_by) + self.manager._check_structure() + + def unchecked_change_influence_by(self, user: ClientManager.Client, change_by: float): + """ + Change the influence of a player by a certain value. If the new influence value goes + below the trial minimum, it is set to the trial minimum. If instead it goes above the + trial maximum, it is set to the trial maximum. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + Client to change. + change_by : float + Amount to change influence by. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + + """ + + if user not in self.get_players(): + raise TrialError.UserNotPlayerError + + new_influence = self._player_to_influence[user.id][0] + change_by + new_influence = max(self._min_influence, min(self._max_influence, new_influence)) + self.unchecked_set_influence(user, new_influence) + + def get_min_influence(self, user: ClientManager.Client) -> float: + """ + Get the current minimum influence of a player of the trial. + + Parameters + ---------- + user : ClientManager.Client + Player to check. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + + Returns + ------- + float + Current minimum influence of the player. + + """ + + try: + return self._player_to_influence[user.id][1] + except KeyError: + raise TrialError.UserNotPlayerError + + def get_max_influence(self, user: ClientManager.Client) -> float: + """ + Get the current maximum influence of a player of the trial. + + Parameters + ---------- + user : ClientManager.Client + Player to check. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + + Returns + ------- + float + Current maximum influence of the player. + + """ + + try: + return self._player_to_influence[user.id][2] + except KeyError: + raise TrialError.UserNotPlayerError + + def get_focus(self, user: ClientManager.Client) -> float: + """ + Get the current focus of a player of the trial. + + Parameters + ---------- + user : ClientManager.Client + Player to check. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + + Returns + ------- + float + Current focus of the player. + + """ + + try: + return self._player_to_focus[user.id][0] + except KeyError: + raise TrialError.UserNotPlayerError + + def set_focus(self, user: ClientManager.Client, new_focus: float): + """ + Set the focus of a player of the trial. + + Parameters + ---------- + user : ClientManager.Client + Client to change. + new_focus : float + New focus. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + TrialError.FocusIsInvalidError + If the new focus is below the trial minimum or above the trial maximum. + + """ + + self.unchecked_set_focus(user, new_focus) + self.manager._check_structure() + + def unchecked_set_focus(self, user: ClientManager.Client, new_focus: float): + """ + Set the focus of a player of the trial. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + Client to change. + new_focus : float + New focus. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + TrialError.FocusIsInvalidError + If the new focus is below the trial minimum or above the trial maximum. + + """ + + if user not in self.get_players(): + raise TrialError.UserNotPlayerError + _, min_focus, max_focus = self._player_to_focus[user.id] + + if not min_focus <= new_focus <= max_focus: + raise TrialError.FocusIsInvalidError + + self._player_to_focus[user.id] = (new_focus, min_focus, max_focus) + user.send_health(side=1, health=int(new_focus)) + + def change_focus_by(self, user: ClientManager.Client, change_by: float): + """ + Change the focus of a player by a certain value. If the new focus value goes + below the trial minimum, it is set to the trial minimum. If instead it goes above the + trial maximum, it is set to the trial maximum. + + Parameters + ---------- + user : ClientManager.Client + Client to change. + change_by : float + Amount to change focus by. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + + """ + + self.unchecked_change_focus_by(user, change_by) + self.manager._check_structure() + + def unchecked_change_focus_by(self, user: ClientManager.Client, change_by: float): + """ + Change the focus of a player by a certain value. If the new focus value goes + below the trial minimum, it is set to the trial minimum. If instead it goes above the + trial maximum, it is set to the trial maximum. + + Parameters + ---------- + user : ClientManager.Client + Client to change. + change_by : float + Amount to change focus by. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + + """ + + if user not in self.get_players(): + raise TrialError.UserNotPlayerError + + new_focus = self._player_to_focus[user.id][0] + change_by + new_focus = max(self._min_focus, min(self._max_focus, new_focus)) + self.unchecked_set_focus(user, new_focus) + + def get_min_focus(self, user: ClientManager.Client) -> float: + """ + Get the current minimum focus of a player of the trial. + + Parameters + ---------- + user : ClientManager.Client + Player to check. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + + Returns + ------- + float + Current minimum focus of the player. + + """ + + try: + return self._player_to_focus[user.id][1] + except KeyError: + raise TrialError.UserNotPlayerError + + def get_max_focus(self, user: ClientManager.Client) -> float: + """ + Get the current maximum focus of a player of the trial. + + Parameters + ---------- + user : ClientManager.Client + Player to check. + + Raises + ------ + TrialError.UserNotPlayerError + If the user is not a player of the trial. + + Returns + ------- + float + Current maximum focus of the player. + + """ + + try: + return self._player_to_focus[user.id][2] + except KeyError: + raise TrialError.UserNotPlayerError + + def new_nsd( + self, + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + # + autoadd_on_creation_existing_users: bool = False, + autoadd_on_trial_player_add: Union[bool, None] = None, + timer_start_value: float = 300, + ) -> _NonStopDebate: + """ + Create a new NSD managed by this trial. Overriden default parameters include: + * An NSD does not require leaders. + + Parameters + ---------- + creator : ClientManager.Client, optional + The player who created this NSD. If set, they will also be added to the NSD. + Defaults to None. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the NSD supports. If None, it + indicates the NSD has no player limit. Defaults to None. + require_invitations : bool, optional + If True, users can only be added to the NSD if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the NSD loses all its players, the NSD will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + trial, and players that switch to something other than a participant character + will be automatically removed from the trial. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the NSD will support. If None, it + indicates the NSD will have no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the NSD will support. If None, it + indicates the NSD will have no timer limit. Defaults to None. + autoadd_on_creation_existing_users : bool, optional + If True, all players of the trial that are in the same area of the creator (if given a + creator) will be automatically added to the NSD. If False, no such check is performed. + Defaults to None. + autoadd_on_trial_player_add : bool, optional + If True, players that are added to the trial will be automatically added if permitted + by the conditions of the game. If False, no such adding will take place. Defaults to + None (use self.autoadd_minigame_on_player_added). + timer_start_value : float, optional + In seconds, the length of time the main timer of this nonstop debate will have at the + start. It must be a positive number. Defaults to 300 (5 minutes). + + Returns + ------- + NonStopDebate + The created NSD. + + Raises + ------ + TrialError.ManagerTooManyGamesError + If the manager is already managing its maximum number of minigames. + Any error from the created NSD's add_player(creator) + If the NSD cannot add `creator` to the NSD if given one. + + """ + + if autoadd_on_trial_player_add is None: + autoadd_on_trial_player_add = self.get_autoadd_minigame_on_player_added() + + areas = {creator.area} if creator else set() + hub = creator.hub + + try: + nsd: _NonStopDebate = self._minigame_manager.new_managee( + managee_type=_NonStopDebate, + creator=None, + player_limit=player_limit, + player_concurrent_limit=1, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=False, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + areas=areas, + area_concurrent_limit=1, + autoadd_on_client_enter=False, + hub=hub, + # kwargs + trial=self, + autoadd_on_trial_player_add=autoadd_on_trial_player_add, + timer_start_value=timer_start_value, + ) + except HubbedGameError.ManagerTooManyGamesError: + raise TrialError.ManagerTooManyGamesError + + nsd.setup_timers() + # Add creator manually. This is because otherwise the creator does not get access to + # the timer info. + try: + if creator: + nsd.add_player(creator) + except NonStopDebateError as ex: + # Discard game + self._minigame_manager.delete_managee(nsd) + raise ex + + if autoadd_on_creation_existing_users: + clients_to_add = {client for area in areas for client in area.clients} + if creator: + clients_to_add.discard(creator) + for client in clients_to_add: + try: + nsd.add_player(client) + except NonStopDebateError.UserNotPlayerError: + continue + + # Manually give packets to nonplayers + for nonplayer in nsd.get_nonplayer_users_in_areas(): + nsd.introduce_user(nonplayer) + + return nsd + + def get_nsd_of_user(self, user: ClientManager.Client) -> _NonStopDebate: + """ + Get the NSD the user is in. + + Parameters + ---------- + user : ClientManager.Client + User to check. + + Raises + ------ + TrialError.UserNotInMinigameError + If the user is not in an NSD managed by this trial. + + Returns + ------- + NonStopDebate + NSD of the user. + + """ + + games = self._minigame_manager.get_managees_of_user(user) + nsds = {game for game in games if isinstance(game, _NonStopDebate)} + if not nsds: + raise TrialError.UserNotInMinigameError + if len(nsds) > 1: + raise RuntimeError(nsds) + return next(iter(nsds)) + + def get_minigames(self) -> Set[_TrialMinigame]: + """ + Return the minigames of this trial. + + Returns + ------- + Set[TrialMinigame] + Trial minigames of this trial. + + """ + + return self._minigame_manager.get_managees() + + def get_minigame_by_id(self, minigame_id: str) -> _TrialMinigame: + """ + If `minigame_id` is the ID of a minigame managed by this trial, return that. + + Parameters + ---------- + minigame_id : str + ID of the minigame this trial manages. + + Returns + ------- + TrialMinigame + The minigame with that ID. + + Raises + ------ + TrialError.ManagerInvalidGameIDError + If `minigame_id` is not the ID of a minigame this game manages. + + """ + + try: + return self._minigame_manager.get_managee_by_id(minigame_id) + except HubbedGameError.ManagerInvalidGameIDError: + raise TrialError.ManagerInvalidGameIDError + + def get_nsd_by_id(self, nsd_id: str) -> _NonStopDebate: + """ + If `nsd_id` is the ID of a nonstop debate managed by this trial, return that. + + Parameters + ---------- + nsd_id : str + ID of the nonstop debate this trial manages. + + Returns + ------- + NonStopDebate + The nonstop debate with that ID. + + Raises + ------ + TrialError.ManagerInvalidGameIDError + If `nsd_id` is not the ID of a nonstop debate this game manages. + + """ + + try: + minigame = self.get_minigame_by_id(nsd_id) + except HubbedGameError.ManagerInvalidGameIDError: + raise TrialError.ManagerInvalidGameIDError + + minigame_type = minigame.get_type() + if minigame_type != TRIALMINIGAMES.NONSTOP_DEBATE: + raise TrialError.ManagerInvalidGameIDError(f'`{nsd_id}` is a minigame of type ' + f'{minigame_type}, not nonstop debate.') + return minigame + + def get_available_minigame_id(self) -> str: + """ + Get a minigame ID that no other minigame managed by this manager has. + + Returns + ------- + str + A unique minigame ID. + + Raises + ------ + TrialError.ManagerTooManyGamesError + If the manager is already managing its maximum number of games. + + """ + + game_number = 0 + game_limit = self._minigame_manager.get_managee_limit() + while game_limit is None or game_number < game_limit: + new_game_id = "{}g{}".format(self.get_id(), game_number) + if new_game_id not in self._minigame_manager.get_managee_ids(): + return new_game_id + game_number += 1 + raise TrialError.ManagerTooManyGamesError + + def get_info(self, include_health: bool = True) -> str: + """ + Obtain a long description of the trial and its players. + + Parameters + ---------- + include_health : bool + If True, the description will include the influence and focus values of the trial + players; if False, these values will be omitted. Defaults to True. + + Returns + ------- + str + Description. + + """ + + tid = self.get_id() + leaders = self.get_leaders() + regulars = self.get_regulars() + + num_members = len(leaders.union(regulars)) + group_texts = list() + for group in (leaders, regulars): + if not group: + group_texts.append('\n*None') + continue + group_text = '' + for player in sorted(group, key=lambda c: c.displayname): + player_text = f'[{player.id}] {player.displayname}' + if include_health: + player_text += ': ' + influence = self.get_influence(player) + focus = self.get_focus(player) + player_text += f'Influence: {influence}; ' + player_text += f'Focus: {focus}' + group_text += f'\n*{player_text}' + group_texts.append(group_text) + + leader_text, regular_text = group_texts + area_ids = ', '.join(sorted({str(area.id) for area in self.get_areas()})) + info = (f'Trial {tid} [{num_members}/-] ({area_ids}).' + f'\nLeaders: {leader_text}' + f'\nRegular members: {regular_text}') + return info + + def unchecked_destroy(self): + """ + Mark this game as destroyed and notify its manager so that it is deleted. + If the game is already destroyed, this function does nothing. + + This method is reentrant (it will do nothing though). + + This method does not assert structural integrity. + + """ + + # Store for later + users = self.get_users_in_areas() + + # Remove minigames first. This is done first so as to enforce explicit destruction + # (rather than rely on other methods). + for game in self._minigame_manager.get_managees(): + game.destroy() + + super().unchecked_destroy() + + self._player_to_focus = dict() + self._player_to_influence = dict() + + # Force every user in the former areas of the trial to be dismissed + for user in users: + self.dismiss_user(user) + + def end(self): + """ + Destroy the trial and play the trial end splash animation to all users in the trial areas. + + This method does not assert structural integrity. + """ + + self.unchecked_end() + self.manager._check_structure() + self._check_structure() + + def unchecked_end(self): + """ + Destroy the trial and play the trial end splash animation to all users in the trial areas. + """ + + users = self.get_users_in_areas() # Store for later + + self.unchecked_destroy() + + for user in users: + user.send_splash(name='testimony2') + + def _on_area_client_left_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False, + ): + """ + If a player left to an area not part of the trial, remove the player and warn them and + the leaders of the trial. + + If a non-plyer left to an area not part of the trial, warn them and the leaders of the + trial. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has left. + client : ClientManager.Client, optional + The client that has left. The default is None. + old_displayname : str, optional + The old displayed name of the client before they changed area. This will typically + change only if the client's character or showname are taken. The default is None. + ignore_bleeding : bool, optional + If the code should ignore actions regarding bleeding. The default is False. + ignore_autopass : bool, optional + If the code should ignore actions regarding autopass. The default is False. + + Returns + ------- + None. + + """ + + if client.area in self.get_areas(): + return + + if client in self.get_players(): + client.send_ooc(f'You have left to an area not part of trial `{self.get_id()}` and ' + f'thus were automatically removed from the trial.') + client.send_ooc_others(f'(X) Player {old_displayname} [{client.id}] has left to ' + f'an area not part of your trial and thus was automatically ' + f'removed it ({area.id}->{client.area.id}).', + pred=lambda c: c in self.get_leaders(), in_hub=area.hub) + + nonplayers = self.get_nonplayer_users_in_areas() + tid = self.get_id() + + self.remove_player(client) + + if self.is_unmanaged(): + client.send_ooc(f'Your trial `{tid}` was automatically ' + f'ended as it lost all its players.') + client.send_ooc_others(f'(X) Trial `{tid}` was automatically ' + f'ended as it lost all its players.', + is_zstaff_flex=True, not_to=nonplayers, in_hub=area.hub) + client.send_ooc_others('The trial you were watching was automatically ended ' + 'as it lost all its players.', + is_zstaff_flex=False, part_of=nonplayers, in_hub=area.hub) + else: + client.send_ooc(f'You have left to an area not part of trial `{self.get_id()}`.') + client.send_ooc_others(f'(X) Player {old_displayname} [{client.id}] has left to ' + f'an area not part of your trial ({area.id}->{client.area.id}).', + pred=lambda c: c in self.get_leaders(), in_hub=area.hub) + self.dismiss_user(client) + + self.manager._check_structure() + + def _on_area_client_entered_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_area: Union[AreaManager.Area, None] = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False, + ): + """ + If a non-player entered, warn them and the leaders of the trial. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has entered. + client : ClientManager.Client, optional + The client that has entered. The default is None. + old_area : AreaManager.Area + The old area the client has come from (possibly None for a newly connected user). The + default is None. + old_displayname : str, optional + The old displayed name of the client before they changed area. This will typically + change only if the client's character or showname are taken. The default is None. + ignore_bleeding : bool, optional + If the code should ignore actions regarding bleeding. The default is False. + ignore_autopass : bool, optional + If the code should ignore actions regarding autopass. The default is False. + + Returns + ------- + None. + + """ + + if client not in self.get_players() and old_area not in self.get_areas(): + old_area_id = str(old_area.id) if old_area else "SERVER_SELECT" + client.send_ooc(f'You have entered an area part of trial `{self.get_id()}`.') client.send_ooc_others(f'(X) Non-player {client.displayname} [{client.id}] has entered ' - f'an area part of your trial ({old_area.id}->{area.id}).', + f'an area part of your trial ({old_area_id}->{area.id}).', pred=lambda c: c in self.get_leaders()) - if self._require_character and not client.has_character(): + if self.requires_participant_characters() and not client.has_participant_character(): if client.is_staff(): - client.send_ooc(f'This trial requires you have a character to join. Join this ' - f'trial with /trial_join {self.get_id()} after choosing a ' - f'character.') - client.send_ooc_others(f'(X) This trial requires players have a character to join. ' + client.send_ooc(f'This trial requires you have a participant character to ' + f'join. Join this trial with /trial_join {self.get_id()} after ' + f'choosing a participant character.') + client.send_ooc_others(f'(X) This trial requires players have a participant ' + f'character to join. ' f'Add {client.displayname} to your trial with ' - f'/trial_add {client.id} after they choose a character.', + f'/trial_add {client.id} after they choose a participant ' + f'character.', pred=lambda c: c in self.get_leaders()) self.introduce_user(client) elif self.get_autoadd_on_client_enter(): @@ -1149,343 +2967,923 @@ def _on_area_client_entered_final(self, area, client=None, old_area=None, old_di 'was automatically added to your trial.', pred=lambda c: c in self.get_leaders()) - # Check if client was added to any minigames automatically or not. - # If so, notifidy leaders - for minigame in self.get_minigames(): - name = minigame.get_name() - if minigame.is_player(client): - client.send_ooc(f'You were automatically added to {name} ' - f'`{minigame.get_id()}`.') - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] was ' - f'automatically added to your {name}.', - pred=lambda c: c in minigame.get_leaders()) - else: - client.send_ooc(f'Unable to be automatically added to {name} ' - f'`{minigame.get_id()}`.') - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] could not ' - f'be automatically added to your {name}.', - pred=lambda c: c in minigame.get_leaders()) + # Check if client was added to any minigames automatically or not. + # If so, notifidy leaders + for minigame in self.get_minigames(): + name = minigame.get_type_name() + if minigame.is_player(client): + client.send_ooc(f'You were automatically added to {name} ' + f'`{minigame.get_id()}`.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] was ' + f'automatically added to your {name}.', + pred=lambda c: c in minigame.get_leaders()) + else: + client.send_ooc(f'Unable to be automatically added to {name} ' + f'`{minigame.get_id()}`.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] could not ' + f'be automatically added to your {name}.', + pred=lambda c: c in minigame.get_leaders()) + + else: + if client.is_staff(): + client.send_ooc(f'Join this trial with /trial_join {self.get_id()}') + client.send_ooc_others(f'(X) Add {client.displayname} to your trial with ' + f'/trial_add {client.id}', + pred=lambda c: c in self.get_leaders()) + self.introduce_user(client) + self.manager._check_structure() + + def _on_client_change_character( + self, + player: ClientManager.Client, + old_char_id: int = -1, + old_char_name: str = '', + new_char_id: int = -1, + new_char_name: str = '', + ): + """ + It checks if the player is now no longer having a participant character. If that is + the case and the trial requires all players have participant characters, the player is + automatically removed. + + Note that it may not necessarily be the case that the following hold: + 1. `old_char_name == player.hub.character_manager.get_character_name(old_char_id)`. + 2. `new_char_name == player.hub.character_manager.get_character_name(new_char_id)`. + This can occur for example if the character list changes, which prompts the player to + change character. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has changed character. + old_char_id : int, optional + Previous character ID. The default is -1. + old_char_name : str, optional + Previous character name. The default is the empty string. + new_char_id : int, optional + New character ID. The default is -1. + new_char_name : int, optional + New character name. The default is the empty string. + + Returns + ------- + None. + + """ + + if self.requires_participant_characters() and not player.has_participant_character(): + player.send_ooc('You were removed from your trial as it required its players to have ' + 'participant characters.') + player.send_ooc_others(f'(X) Player {player.id} changed character from {old_char_name} ' + f'to a non-participant character and was thus removed from your ' + f'trial.', + pred=lambda c: c in self.get_leaders()) + + nonplayers = self.get_nonplayer_users_in_areas() + tid = self.get_id() + + self.remove_player(player) + + if self.is_unmanaged(): + player.send_ooc(f'Your trial `{tid}` was automatically ' + f'ended as it lost all its players.') + player.send_ooc_others(f'(X) Trial `{tid}` was automatically ' + f'ended as it lost all its players.', + is_zstaff_flex=True, not_to=nonplayers) + player.send_ooc_others('The trial you were watching was automatically ended ' + 'as it lost all its players.', + is_zstaff_flex=False, part_of=nonplayers) + else: + player.send_ooc_others(f'(X) Player {player.id} changed character from {old_char_name} ' + f'to {player.get_char_name()} in your trial.', + pred=lambda c: c in self.get_leaders()) + self.manager._check_structure() + + def _on_client_destroyed(self, player: ClientManager.Client): + """ + Remove the player from the trial. If the trial is already unmanaged or + the player is not in the game, this callback does nothing. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it was destroyed. + + Returns + ------- + None. + + """ + + if self.is_unmanaged(): + return + if player not in self.get_players(): + return + + player.send_ooc_others(f'(X) Player {player.displayname} of your trial disconnected. ' + f'({player.area.id})', pred=lambda c: c in self.get_leaders()) + nonplayers = self.get_nonplayer_users_in_areas() + tid = self.get_id() + + self.remove_player(player) + + if self.is_unmanaged(): + # player.send_ooc(f'Your trial `{tid}` was automatically ' + # f'ended as it lost all its players.') + player.send_ooc_others(f'(X) Trial `{tid}` was automatically ' + f'ended as it lost all its players.', + is_zstaff_flex=True, not_to=nonplayers) + player.send_ooc_others('The trial you were watching was automatically ended ' + 'as it lost all its players.', + is_zstaff_flex=False, part_of=nonplayers) + + self.manager._check_structure() + + def _on_client_inbound_rt(self, player: ClientManager.Client, contents: Dict[str, Any]): + """ + Callback for trial player signaling they have used a splash button. + + If the splash button is "testimony2" and the player is a leader of the trial, the trial + is automatically ended. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has used a splash button. + contents : Dict[str, Any] + Arguments of the splash packet as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + if contents['name'] == 'testimony2': + # Trial end button + if self.is_leader(player): + # Save leaders and regulars before destruction + leaders = self.get_leaders() + regulars = self.get_regulars() + nonplayers = self.get_nonplayer_users_in_areas() + self.destroy() + + player.send_ooc('You ended your trial.') + player.send_ooc_others('The trial you were watching was ended.', + pred=lambda c: c in nonplayers) + player.send_ooc_others('Your trial was ended.', + pred=lambda c: c in regulars) + player.send_ooc_others(f'(X) {player.displayname} [{player.id}] ended your trial.', + pred=lambda c: c in leaders) + + def _on_areas_loaded(self, area_manager: AreaManager): + """ + Destroy the trial and warn players and nonplayers in areas. + + Parameters + ---------- + area_manager : AreaManager + AreaManager that signaled the area list load. + + Returns + ------- + None. + + """ + + for nonplayer in self.get_nonleader_users_in_areas(): + nonplayer.send_ooc('The trial you were watching was deleted due to an area list load.') + for player in self.get_players(): + player.send_ooc('Your trial was deleted due to an area list load.') + + self.destroy() + + def _check_structure(self): + """ + Assert that all invariants specified in the class description are maintained. + + Raises + ------ + AssertionError + If any of the invariants are not maintained. + + """ + + _id = self.get_id() + + # 1. + for game in self.get_minigames(): + for player in game.get_players(): + assert player in self.get_players(), ( + f'For trial {_id}, expected that player {player} of its minigame ' + f'{game} was a player of the trial, found that was not the case. || {self}' + ) + + # 2. + for game in self.get_minigames(): + for area in game.get_areas(): + assert area in self.get_areas(), ( + f'For trial {_id}, expected that area {area} of its minigame ' + f'{game} was an area of the trial, found that was not the case. || {self}' + ) + + # 3. + for player in self.get_players(): + assert player.id in self._player_to_influence, ( + f'For trial {_id}, expected that player {player} of the trial appeared in the ' + f'player to influence map of the trial {self._player_to_influence}, found that was ' + f'not the case. || {self}' + ) + + assert player.id in self._player_to_focus, ( + f'For trial {_id}, expected that player {player} of the trial appeared in the ' + f'player to focus map of the trial {self._player_to_focus}, found that was ' + f'not the case. || {self}' + ) + + player_ids = {player.id for player in self.get_players()} + for player_id in self._player_to_influence: + assert player_id in player_ids, ( + f'For trial {_id}, expected that player with ID {player_id} that appeared ' + f'in the player to influence map of the trial {self._player_to_influence} was ' + f'a player of the trial, found that was not the case. || {self}' + ) + + for player_id in self._player_to_focus: + assert player_id in player_ids, ( + f'For trial {_id}, expected that player with ID {player_id} that appeared ' + f'in the player to focus map of the trial {self._player_to_focus} was ' + f'a player of the trial, found that was not the case. || {self}' + ) + + # 4. + for (player_id, influences) in self._player_to_influence.items(): + assert isinstance(influences, tuple) and len(influences) == 3, ( + f'For trial {_id}, expected that the player with ID {player_id} had a ' + f'3-tuple of current influence, min influence and max influence associated ' + f'to it in the player to influence map, found it was {influences} instead. ' + f'|| {self}' + ) + + influence, min_influence, max_influence = self._player_to_influence[player_id] + all_numbers = [isinstance(value, (int, float)) for value in influences] + assert all(all_numbers), ( + f'For trial {_id}, expected that the player with ID {player_id} had a ' + f'3-tuple of floats associated to it in the player to influence map, found it ' + f'was {influences} instead. || {self}' + ) + + assert min_influence <= influence <= max_influence, ( + f'For trial {_id}, expected that player with ID {player_id} had an influence ' + f'value between {min_influence} and {max_influence} inclusive, ' + f'found it was {influence} instead. || {self}' + ) + + for (player_id, focuses) in self._player_to_focus.items(): + assert isinstance(focuses, tuple) and len(focuses) == 3, ( + f'For trial {_id}, expected that the player with ID {player_id} had a ' + f'3-tuple of current focus, min focus and max focus associated ' + f'to it in the player to focus map, found it was {focuses} instead. || {self}' + ) + + focus, min_focus, max_focus = self._player_to_focus[player_id] + all_numbers = [isinstance(value, (int, float)) for value in focuses] + assert all(all_numbers), ( + f'For trial {_id}, expected that the player with ID {player_id} had a ' + f'3-tuple of floats associated to it in the player to focus map, found it ' + f'was {focuses} instead. || {self}' + ) + + assert min_focus <= focus <= max_focus, ( + f'For trial {self}, expected that player with ID {player_id} had an focus ' + f'value between {min_focus} and {max_focus} inclusive, ' + f'found it was {focus} instead. || {self}' + ) + + # 5. + super()._check_structure() + + def __str__(self): + """ + Return a string representation of this trial. + + Returns + ------- + str + Representation. + + """ + + return (f"Trial::{self.get_id()}:" + f"{self.get_players()}:{self.get_leaders()}:{self.get_invitations()}" + f"{self.get_timers()}:" + f"{self.get_teams()}:" + f"{self.get_areas()}:" + f"{self.get_minigames()}") + + def __repr__(self): + """ + Return a representation of this trial. + + Returns + ------- + str + Printable representation. + + """ + + return (f'Trial(server, {self.manager.get_id()}, "{self.get_id()}", ' + f'player_limit={self.get_player_limit()}, ' + f'player_concurrent_limit={self.get_player_concurrent_limit()}, ' + f'require_players={self.requires_players()}, ' + f'require_invitations={self.requires_invitations()}, ' + f'require_leaders={self.requires_leaders()}, ' + f'require_participant_character={self.requires_participant_characters()}, ' + f'team_limit={self.get_team_limit()}, ' + f'timer_limit={self.get_timer_limit()}, ' + f'areas={self.get_areas()}), ' + f'|| ' + f'players={self.get_players()}, ' + f'invitations={self.get_invitations()}, ' + f'leaders={self.get_leaders()}, ' + f'timers={self.get_timers()}, ' + f'teams={self.get_teams()}, ' + f'areas={self.get_areas()}), ' + f'minigames={self.get_minigames()}, ' + f'unmanaged={self.is_unmanaged()}), ' + f')') + +class _TrialManagerTrivialInherited(HubbedGameManager): + """ + This class should not be instantiated. + """ + + def new_managee( + self, + managee_type: Type[_Trial] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = False, # Overriden from parent + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + areas: Set[AreaManager.Area] = None, + area_concurrent_limit: Union[int, None] = 1, # Overriden from parent + autoadd_on_client_enter: bool = False, + autoadd_on_creation_existing_users: bool = False, + require_areas: bool = True, + hub: Union[_Hub, None] = None, + # new + autoadd_minigame_on_player_added: bool = False, + **kwargs: Any, + ) -> _Trial: + """ + Create a new trial managed by this manager. Overriden default parameters include: + * A trial does not require leaders. + * An area cannot belong to two or more trials at the same time. + + Parameters + ---------- + creator : ClientManager.Client, optional + The player who created this trial. If set, they will also be added to the trial. + Defaults to None. + player_limit : Union[int, None], optional + If an int, it is the maximum number of players the trial supports. If None, it + indicates the trial has no player limit. Defaults to None. + require_invitations : bool, optional + If True, users can only be added to the trial if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the trial loses all its players, the trial will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + trial, and players that switch to something other than a participant character + will be automatically removed from the trial. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional + If an int, it is the maximum number of teams the trial will support. If None, it + indicates the trial will have no team limit. Defaults to None. + timer_limit : Union[int, None], optional + If an int, it is the maximum number of timers the trial will support. If None, it + indicates the trial will have no timer limit. Defaults to None. + area_concurrent_limit : Union[int, None], optional + If an int, it is the maximum number of trials managed by `manager` that any + area of the created trial may belong to, including the created trial. If None, it + indicates that this trial does not care about how many other trials managed by + `manager` each of its areas belongs to. Defaults to 1 (an area may not be a part of + another trial managed by `manager` while being an area of this trials). + autoadd_on_client_enter : bool, optional + If True, nonplayer users that enter an area part of the game will be automatically + added if permitted by the conditions of the game. If False, no such adding will take + place. Defaults to False. + autoadd_on_creation_existing_users : bool + If the trial will attempt to add nonplayer users who were in an area added + to the trial on creation. Defaults to False. + require_areas : bool, optional + If True, if at any point the trial has no areas left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + hub : _Hub, optional + Hub of the hubbed game. Defaults to None (and converted to the creator's hub if given a + creator, and None otherwise). + autoadd_minigame_on_player_added : bool, optional + If True, nonplayer users that are added to the trial will also be automatically added + to the minigame if permitted by its conditions. If False, no such adding will take + place. Defaults to False. + + Returns + ------- + _Trial + The created trial. + + Raises + ------ + TrialError.AreaDisallowsBulletsError + If `creator` is given and the area of the creator disallows bullets. + TrialError.ManagerTooManyGamesError + If the manager is already managing its maximum number of minigames. + Any error from the created trial's add_player(creator) + If the trial cannot add `creator` to the trial if given one. + + """ + + if managee_type is None: + managee_type = self.get_managee_type() + + trial = self.unchecked_new_managee( + managee_type=managee_type, + creator=creator, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + areas=areas, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + autoadd_on_creation_existing_users=autoadd_on_creation_existing_users, + require_areas=require_areas, + hub=hub, + autoadd_minigame_on_player_added=autoadd_minigame_on_player_added, + **kwargs, + ) + self._check_structure() + + return trial + + def get_managee_type(self) -> Type[_Trial]: + """ + Return the type of the trial that will be constructed by default with a call of + `new_managee`. + + Returns + ------- + Type[_Trial] + Type of the trial. + + """ + + return super().get_managee_type() + + def delete_managee(self, managee: _Trial) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a trial managed by this manager, so all its players no longer belong to + this trial. + + Parameters + ---------- + managee : _Trial + The trial to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the trial that was deleted. + + Raises + ------ + TrialError.ManagerDoesNotManageGameError + If the manager does not manage the target trial. + + """ + + game_id, game_players = self.unchecked_delete_managee(managee) + self._check_structure() + return game_id, game_players + + def unchecked_delete_managee( + self, + managee: _Trial + ) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a trial managed by this manager, so all its players no longer belong to + this trial. + + Parameters + ---------- + managee : _Trial + The trial to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the trial that was deleted. + + Raises + ------ + TrialError.ManagerDoesNotManageGameError + If the manager does not manage the target trial. - else: - if client.is_staff(): - client.send_ooc(f'Join this trial with /trial_join {self.get_id()}') - client.send_ooc_others(f'(X) Add {client.displayname} to your trial with ' - f'/trial_add {client.id}', - pred=lambda c: c in self.get_leaders()) - self.introduce_user(client) - self._check_structure() + """ + + try: + return super().unchecked_delete_managee(managee) + except HubbedGameError.ManagerDoesNotManageGameError: + raise TrialError.ManagerDoesNotManageGameError - def _on_client_change_character(self, player, old_char_id=None, new_char_id=None): + def manages_managee(self, game: _Trial): """ - It checks if the player is now no longer having a character. If that is - the case and the trial requires all players have characters, the player is automatically - removed. + Return True if the trial is managed by this manager, False otherwise. Parameters ---------- - player : ClientManager.Client - Player that signaled it has changed character. - old_char_id : int, optional - Previous character ID. The default is None. - new_char_id : int, optional - New character ID. The default is None. + game : _Trial + The game to check. Returns ------- - None. + bool + True if the manager manages this trial, False otherwise. """ - old_char = player.get_char_name(old_char_id) - if self._require_character and not player.has_character(): - player.send_ooc('You were removed from your trial as it required its players to have ' - 'characters.') - player.send_ooc_others(f'(X) Player {player.id} changed character from {old_char} to ' - f'a non-character and was removed from your trial.', - pred=lambda c: c in self.get_leaders()) + return super().manages_managee(game) - nonplayers = self.get_nonplayer_users_in_areas() - tid = self.get_id() + def get_managees(self) -> Set[_Trial]: + """ + Return (a shallow copy of) the trials this manager manages. - self.remove_player(player) + Returns + ------- + Set[_Trial] + Trials this manager manages. - if self.is_unmanaged(): - player.send_ooc(f'Your trial `{tid}` was automatically ' - f'ended as it lost all its players.') - player.send_ooc_others(f'(X) Trial `{tid}` was automatically ' - f'ended as it lost all its players.', - is_zstaff_flex=True, not_to=nonplayers) - player.send_ooc_others('The trial you were watching was automatically ended ' - 'as it lost all its players.', - is_zstaff_flex=False, part_of=nonplayers) - else: - player.send_ooc_others(f'(X) Player {player.id} changed character from {old_char} ' - f'to {player.get_char_name()} in your trial.', - pred=lambda c: c in self.get_leaders()) - self._check_structure() + """ - def _on_client_destroyed(self, player): + return super().get_managees() + + def get_managee_by_id(self, managee_id: str) -> _Trial: """ - Remove the player from the trial. If the trial is already unmanaged or - the player is not in the game, this callback does nothing. + If `managee_id` is the ID of a trial managed by this manager, return that. Parameters ---------- - player : ClientManager.Client - Player that signaled it was destroyed. + managee_id : str + ID of the trial this manager manages. Returns ------- - None. + _Trial + The trial with that ID. + + Raises + ------ + TrialError.ManagerInvalidGameIDError + If `game_id` is not the ID of a trial this manager manages. """ - if self.is_unmanaged(): - return - if player not in self.get_players(): - return + try: + return super().get_managee_by_id(managee_id) + except HubbedGameError.ManagerInvalidGameIDError: + raise TrialError.ManagerInvalidGameIDError - player.send_ooc_others(f'(X) Player {player.displayname} of your trial disconnected. ' - f'({player.area.id})', pred=lambda c: c in self.get_leaders()) - nonplayers = self.get_nonplayer_users_in_areas() - tid = self.get_id() + def get_managee_by_numerical_id(self, managee_numerical_id: Union[str, int]) -> _Trial: + """ + If `managee_numerical_id` is the numerical ID of a trial managed by this manager, + return the trial. - self.remove_player(player) + Parameters + ---------- + managee_numerical_id : Union[str, int] + Numerical ID of the trial this manager manages. - if self.is_unmanaged(): - # player.send_ooc(f'Your trial `{tid}` was automatically ' - # f'ended as it lost all its players.') - player.send_ooc_others(f'(X) Trial `{tid}` was automatically ' - f'ended as it lost all its players.', - is_zstaff_flex=True, not_to=nonplayers) - player.send_ooc_others('The trial you were watching was automatically ended ' - 'as it lost all its players.', - is_zstaff_flex=False, part_of=nonplayers) + Returns + ------- + _Trial + The trial with that ID. - self._check_structure() + Raises + ------ + TrialError.ManagerInvalidGameIDError: + If `managee_numerical_id` is not the numerical ID of a trial + this manager manages. - def _on_client_inbound_rt(self, player, contents): """ - Callback for trial player signaling they have used a splash button. - If the splash button is "testimony2" and the player is a leader of the trial, the trial - is automatically ended. + try: + return super().get_managee_by_numerical_id(managee_numerical_id) + except HubbedGameError.ManagerInvalidGameIDError: + raise TrialError.ManagerInvalidGameIDError - Parameters - ---------- - player : ClientManager.Client - Player that signaled it has used a splash button. - contents : dict of str to Any - Arguments of the splash packet as indicated in AOProtocol. + def get_managee_limit(self) -> Union[int, None]: + """ + Return the trial limit of this manager. Returns ------- - None. + Union[int, None] + Trial limit. """ - if contents['name'] == 'testimony2': - # Trial end button - if self.is_leader(player): - # Save leaders and regulars before destruction - leaders = self.get_leaders() - regulars = self.get_regulars() - nonplayers = self.get_nonplayer_users_in_areas() - self.destroy() - player.send_ooc('You ended your trial.') - player.send_ooc_others('The trial you were watching was ended.', - pred=lambda c: c in nonplayers) - player.send_ooc_others('Your trial was ended.', - pred=lambda c: c in regulars) - player.send_ooc_others(f'(X) {player.displayname} [{player.id}] ended your trial.', - pred=lambda c: c in leaders) + return super().get_managee_limit() - def _on_areas_loaded(self, area_manager): + def get_managee_ids(self) -> Set[str]: """ - Destroy the trial and warn players and nonplayers in areas. + Return (a shallow copy of) the IDs of all trials managed by this manager. + + Returns + ------- + Set[str] + The IDs of all managed trials. + + """ + + return super().get_managee_ids() + + def get_managee_ids_to_managees(self) -> Dict[str, _Trial]: + """ + Return a mapping of the IDs of all trials managed by this manager to their + associated trial. + + Returns + ------- + Dict[str, _Trial] + Mapping. + """ + + return super().get_managee_ids_to_managees() + + def get_managee_numerical_ids_to_managees(self) -> Dict[int, _Trial]: + """ + Return a mapping of the numerical IDs of all trials managed by this manager to + their associated trial. + + Returns + ------- + Dict[int, _Trial] + Mapping. + """ + + return super().get_managee_numerical_ids_to_managees() + + def get_managees_of_user(self, user: ClientManager.Client): + """ + Return (a shallow copy of) the trials managed by this manager user `user` is a + player of. If the user is part of no such trial, an empty set is returned. Parameters ---------- - area_manager : AreaManager - AreaManager that signaled the area list load. + user : ClientManager.Client + User whose trials will be returned. Returns ------- - None. + Set[_Trial] + Trials the player belongs to. """ - for nonplayer in self.get_nonleader_users_in_areas(): - nonplayer.send_ooc('The trial you were watching was deleted due to an area list load.') - for player in self.get_players(): - player.send_ooc('Your trial was deleted due to an area list load.') + return super().get_managees_of_user(user) - self.destroy() + def get_player_to_managees_map(self) -> Dict[ClientManager.Client, Set[_Trial]]: + """ + Return a mapping of the players part of any trial managed by this manager to the + trial managed by this manager such players belong to. - def __str__(self): + Returns + ------- + Dict[ClientManager.Client, Set[_Trial]] + Mapping. """ - Return a string representation of this trial. + + return super().get_player_to_managees_map() + + def get_users_in_some_managee(self) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) all the users that are part of some trial managed by + this manager. Returns ------- - str - Representation. + Set[ClientManager.Client] + Users in some managed trial. """ - return (f"Trial::{self.get_id()}:" - f"{self.get_players()}:{self.get_leaders()}:{self.get_invitations()}" - f"{self.get_timers()}:" - f"{self.get_teams()}:" - f"{self.get_areas()}:" - f"{self.get_minigames()}") + return super().get_users_in_some_managee() - def __repr__(self): + def is_managee_creatable(self) -> bool: """ - Return a representation of this trial. + Return whether a new trial can currently be created without creating one. Returns ------- - str - Printable representation. + bool + True if a trial can be currently created, False otherwise. + """ + return super().is_managee_creatable() + + def get_managees_in_area(self, area: AreaManager.Area) -> Set[_Trial]: """ + Return (a shallow copy of) all trials managed by this manager that contain + the given area. - return (f'Trial(server, {self._manager.get_id()}, "{self.get_id()}", ' - f'player_limit={self._playergroup._player_limit}, ' - f'player_concurrent_limit={self.get_player_concurrent_limit()}, ' - f'require_players={self._playergroup._require_players}, ' - f'require_invitations={self._playergroup._require_invitations}, ' - f'require_leaders={self._playergroup._require_leaders}, ' - f'require_character={self._require_character}, ' - f'team_limit={self._team_manager.get_group_limit()}, ' - f'timer_limit={self._timer_manager.get_timer_limit()}, || ' - f'players={self.get_players()}, ' - f'invitations={self.get_invitations()}, ' - f'leaders={self.get_leaders()}, ' - f'timers={self.get_timers()}, ' - f'teams={self.get_teams()}, ' - f'areas={self.get_areas()}), ' - f'minigames={self.get_minigames()}') + Parameters + ---------- + area : AreaManager.Area + Area that all returned trials must contain. + + Returns + ------- + Set[_Trial] + Trials that contain the given area. - def _check_structure(self): """ - Assert that all invariants specified in the class description are maintained. - Raises - ------ - AssertionError - If any of the invariants are not maintained. + return super().get_managees_in_area(area) + def find_area_concurrent_limiting_managee( + self, + area: AreaManager.Area + ) -> Union[_Trial, None]: """ + For area `area`, find a trial `most_restrictive_game` managed by this manager + such that, if `area` were to be added to another trial managed by this manager, + they would violate `most_restrictive_game`'s concurrent area membership limit. + If no such trial exists (or the area is not an area of any trial + managed by this manager), return None. + If multiple such trials exist, any one of them may be returned. - # 1. - for game in self.get_minigames(): - for player in game.get_players(): - err = (f'For trial {self}, expected that player {player} of its minigame ' - f'{game} was a player of the trial, found that was not the case.') - assert player in self.get_players(), err + Parameters + ---------- + area : AreaManager.Area + Area to test. - # 2. - for game in self.get_minigames(): - for area in game.get_areas(): - err = (f'For trial {self}, expected that area {area} of its minigame ' - f'{game} was an area of the trial, found that was not the case.') - assert area in self.get_areas(), err + Returns + ------- + Union[_Trial, None] + Limiting trial as previously described if it exists, None otherwise. - # 3. - for player in self.get_players(): - err = (f'For trial {self}, expected that player {player} of the trial appeared in the ' - f'player to influence map of the trial {self._player_to_influence}, found that ' - f'was not the case.') - assert player.id in self._player_to_influence, err + """ - err = (f'For trial {self}, expected that player {player} of the trial appeared in the ' - f'player to focus map of the trial {self._player_to_focus}, found that ' - f'was not the case.') - assert player.id in self._player_to_focus, err + return super().find_area_concurrent_limiting_managee(area) - player_ids = {player.id for player in self.get_players()} - for player_id in self._player_to_influence: - err = (f'For trial {self}, expected that player with ID {player_id} that appeared ' - f'in the player to influence map of the trial {self._player_to_influence} was ' - f'a player of the trial, found that was not the case.') - assert player_id in player_ids, err + def get_areas_to_managees_map(self) -> Dict[ClientManager.Client, Set[_Trial]]: + """ + Return a mapping of the areas part of any trial managed by this manager to the + trial managed by this manager such players belong to. - for player_id in self._player_to_focus: - err = (f'For trial {self}, expected that player with ID {player_id} that appeared ' - f'in the player to focus map of the trial {self._player_to_focus} was ' - f'a player of the trial, found that was not the case.') - assert player_id in player_ids, err + Returns + ------- + Dict[ClientManager.Client, Set[_Trial]] + Mapping. + """ - # 4. - for (player_id, influences) in self._player_to_influence.items(): - err = (f'For trial {self}, expected that the player with ID {player_id} had a ' - f'3-tuple of current influence, min influence and max influence associated ' - f'to it in the player to influence map, found it was {influences} instead.') - assert isinstance(influences, tuple) and len(influences) == 3, err + return super().get_areas_to_managees_map() - influence, min_influence, max_influence = self._player_to_influence[player_id] - err = (f'For trial {self}, expected that the player with ID {player_id} had a ' - f'3-tuple of floats associated to it in the player to influence map, found it ' - f'was {influences} instead.') + def get_id(self) -> str: + """ + Return the ID of this manager. This ID is guaranteed to be unique among + simultaneously existing Python objects. - all_numbers = [isinstance(value, (int, float)) for value in influences] - assert all(all_numbers), err + Returns + ------- + str + ID. + + """ - err = (f'For trial {self}, expected that player with ID {player_id} had an influence ' - f'value between {min_influence} and {max_influence} inclusive, ' - f'found it was {influence} instead.') - assert min_influence <= influence <= max_influence, err + return super().get_id() - for (player_id, focuses) in self._player_to_focus.items(): - err = (f'For trial {self}, expected that the player with ID {player_id} had a ' - f'3-tuple of current focus, min focus and max focus associated ' - f'to it in the player to focus map, found it was {focuses} instead.') - assert isinstance(focuses, tuple) and len(focuses) == 3, err + def find_player_concurrent_limiting_managee( + self, + user: ClientManager.Client + ) -> Union[_Trial, None]: + """ + For user `user`, find a trial `most_restrictive_game` managed by this manager such + that, if `user` were to join another trial managed by this manager, they would + violate `most_restrictive_game`'s concurrent player membership limit. + If no such trial exists (or the player is not member of any trial + managed by this manager), return None. + If multiple such trials exist, any one of them may be returned. - focus, min_focus, max_focus = self._player_to_focus[player_id] - err = (f'For trial {self}, expected that the player with ID {player_id} had a ' - f'3-tuple of floats associated to it in the player to focus map, found it ' - f'was {focuses} instead.') + Parameters + ---------- + user : ClientManager.Client + User to test. - all_numbers = [isinstance(value, (int, float)) for value in focuses] - assert all(all_numbers), err + Returns + ------- + Union[_Trial, None] + Limiting trial as previously described if it exists, None otherwise. - err = (f'For trial {self}, expected that player with ID {player_id} had an focus ' - f'value between {min_focus} and {max_focus} inclusive, ' - f'found it was {focus} instead.') - assert min_focus <= focus <= max_focus, err + """ - # 5. - super()._check_structure() + return super().find_player_concurrent_limiting_managee(user) -class TrialManager(GameWithAreasManager): +class TrialManager(_TrialManagerTrivialInherited): """ - A trial manager is a game with areas manager with dedicated trial management functions. + A trial manager is a hubbed game manager with dedicated trial management functions. + + Attributes + ---------- + server : TsuserverDR + Server the trial manager belongs to. """ - # TODO: Enforce GameWithAreasManager to only take game with areas as games when calling - # new_game, or when initialized. Also do it in check_structure() + # Invariants + # ---------- + # 1. The invariants of the parent class are maintained. + + def __init__( + self, + server: TsuserverDR, + managee_limit: Union[int, None] = None, + default_managee_type: Type[_Trial] = None, + ): + """ + Create a trial manager object. - def new_trial(self, creator=None, player_limit=None, player_concurrent_limit=1, - add_players=False, require_invitations=False, require_players=True, - require_character=False, team_limit=None, timer_limit=None, - area_concurrent_limit=1, autoadd_on_client_enter=False, - autoadd_minigame_on_player_added=False) -> _Trial: + Parameters + ---------- + server : TsuserverDR + The server this trial manager belongs to. + managee_limit : int, optional + The maximum number of trial this manager can handle. Defaults to None + (no limit). + default_managee_type : Type[_Trial], optional + The default type of trial this manager will create. Defaults to None (and then + converted to _Trial). + + """ + + if default_managee_type is None: + default_managee_type = _Trial + + super().__init__( + server, + managee_limit=managee_limit, + default_managee_type=default_managee_type + ) + + def unchecked_new_managee( + self, + managee_type: Type[_Trial] = None, + creator: Union[ClientManager.Client, None] = None, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = 1, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = False, # Overriden from parent + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + areas: Set[AreaManager.Area] = None, + area_concurrent_limit: Union[int, None] = 1, # Overriden from parent + autoadd_on_client_enter: bool = False, + autoadd_on_creation_existing_users: bool = False, + require_areas: bool = True, + hub: Union[_Hub, None] = None, + # new + autoadd_minigame_on_player_added: bool = False, + **kwargs: Any, + ) -> _Trial: """ Create a new trial managed by this manager. Overriden default parameters include: * A trial does not require leaders. - * A trial adds only the creator's area if given a creator, or no area otherwise. + * An area cannot belong to two or more trials at the same time. + + This method does not assert structural integrity. Parameters ---------- creator : ClientManager.Client, optional The player who created this trial. If set, they will also be added to the trial. Defaults to None. - player_limit : int or None, optional + player_limit : Union[int, None], optional If an int, it is the maximum number of players the trial supports. If None, it indicates the trial has no player limit. Defaults to None. require_invitations : bool, optional @@ -1494,19 +3892,19 @@ def new_trial(self, creator=None, player_limit=None, player_concurrent_limit=1, require_players : bool, optional If True, if at any point the trial loses all its players, the trial will automatically be deleted. If False, no such automatic deletion will happen. Defaults to True. - require_character : bool, optional - If False, players without a character will not be allowed to join the trial, and - players that switch to something other than a character will be automatically - removed from the trial. If False, no such checks are made. A player without a - character is considered one where player.has_character() returns False. Defaults - to False. - team_limit : int or None, optional + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + trial, and players that switch to something other than a participant character + will be automatically removed from the trial. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. + team_limit : Union[int, None], optional If an int, it is the maximum number of teams the trial will support. If None, it indicates the trial will have no team limit. Defaults to None. - timer_limit : int or None, optional + timer_limit : Union[int, None], optional If an int, it is the maximum number of timers the trial will support. If None, it indicates the trial will have no timer limit. Defaults to None. - area_concurrent_limit : int or None, optional + area_concurrent_limit : Union[int, None], optional If an int, it is the maximum number of trials managed by `manager` that any area of the created trial may belong to, including the created trial. If None, it indicates that this trial does not care about how many other trials managed by @@ -1516,6 +3914,16 @@ def new_trial(self, creator=None, player_limit=None, player_concurrent_limit=1, If True, nonplayer users that enter an area part of the game will be automatically added if permitted by the conditions of the game. If False, no such adding will take place. Defaults to False. + autoadd_on_creation_existing_users : bool + If the trial will attempt to add nonplayer users who were in an area added + to the trial on creation. Defaults to False. + require_areas : bool, optional + If True, if at any point the trial has no areas left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + hub : _Hub, optional + Hub of the hubbed game. Defaults to None (and converted to the creator's hub if given a + creator, and None otherwise). autoadd_minigame_on_player_added : bool, optional If True, nonplayer users that are added to the trial will also be automatically added to the minigame if permitted by its conditions. If False, no such adding will take @@ -1530,40 +3938,40 @@ def new_trial(self, creator=None, player_limit=None, player_concurrent_limit=1, ------ TrialError.AreaDisallowsBulletsError If `creator` is given and the area of the creator disallows bullets. - GameError.ManagerTooManyGamesError + TrialError.ManagerTooManyGamesError If the manager is already managing its maximum number of minigames. Any error from the created trial's add_player(creator) If the trial cannot add `creator` to the trial if given one. """ - areas = {creator.area} if creator else set() + if managee_type is None: + managee_type = self.get_managee_type() - trial_factory = functools.partial( - _Trial, - autoadd_minigame_on_player_added=autoadd_minigame_on_player_added - ) - trial = self.new_game(game_type=trial_factory, creator=creator, - player_limit=player_limit, - player_concurrent_limit=player_concurrent_limit, - require_invitations=require_invitations, - require_players=require_players, - require_leaders=False, - require_character=require_character, - team_limit=team_limit, timer_limit=timer_limit, - areas=areas, area_concurrent_limit=area_concurrent_limit, - autoadd_on_client_enter=autoadd_on_client_enter) - - if add_players: - clients_to_add = {client for area in areas for client in area.clients} - if creator: - clients_to_add.discard(creator) - for client in clients_to_add: - try: - trial.add_player(client) - except GameError as ex: - trial.destroy() - raise ex + try: + trial: _Trial = super().unchecked_new_managee( + managee_type=managee_type, + creator=creator, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + areas=areas, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + autoadd_on_creation_existing_users=autoadd_on_creation_existing_users, + require_areas=require_areas, + hub=hub, + # kwargs + autoadd_minigame_on_player_added=autoadd_minigame_on_player_added, + **kwargs, + ) + except HubbedGameError.ManagerTooManyGamesError: + raise TrialError.ManagerTooManyGamesError # Manually give packets to nonplayers for nonplayer in trial.get_nonplayer_users_in_areas(): @@ -1571,7 +3979,7 @@ def new_trial(self, creator=None, player_limit=None, player_concurrent_limit=1, return trial - def get_trial_of_user(self, user) -> _Trial: + def get_managee_of_user(self, user: ClientManager.Client) -> _Trial: """ Get the trial the user is in. @@ -1582,7 +3990,7 @@ def get_trial_of_user(self, user) -> _Trial: Raises ------ - GameError.UserNotPlayerError + TrialError.UserNotPlayerError If the user is not in a trial managed by this manager. Returns @@ -1592,15 +4000,15 @@ def get_trial_of_user(self, user) -> _Trial: """ - games = self.get_games_of_user(user) + games = self.get_managees_of_user(user) trials = {game for game in games if isinstance(game, _Trial)} if not trials: - raise GameError.UserNotPlayerError + raise TrialError.UserNotPlayerError if len(trials) > 1: raise RuntimeError(trials) return next(iter(trials)) - def get_available_game_id(self): + def get_available_managee_id(self): """ Get a trial ID that no other trial managed by this manager has. @@ -1611,19 +4019,32 @@ def get_available_game_id(self): Raises ------ - GameError.ManagerTooManyGamesError + TrialError.ManagerTooManyGamesError If the manager is already managing its maximum number of games. """ game_number = 0 - game_limit = self.get_game_limit() + game_limit = self.get_managee_limit() while game_limit is None or game_number < game_limit: - new_game_id = "trial{}".format(game_number) - if new_game_id not in self.get_game_ids(): + new_game_id = f'trial{game_number}' + if new_game_id not in self.get_managee_ids(): return new_game_id game_number += 1 - raise GameError.ManagerTooManyGamesError + raise TrialError.ManagerTooManyGamesError + + def _check_structure(self): + """ + Assert that all invariants specified in the class description are maintained. + + Raises + ------ + AssertionError + If any of the invariants are not maintained. + + """ + + super()._check_structure() def __repr__(self): """ @@ -1636,7 +4057,11 @@ def __repr__(self): """ - return (f"TrialManager(server, game_limit={self.get_game_limit()}, " + return (f"TrialManager(server, managee_limit={self.get_managee_limit()}, " + f"default_managee_type={self.get_managee_type()}, " f"|| " - f"_trials={self.get_games()}, " - f"id={hex(id(self))})") + f"_id_to_managee={self.get_managee_ids_to_managees()}, " + f"_user_to_managees={self.get_player_to_managees_map()}, " + f"_area_to_managees={self.get_areas_to_managees_map()}, " + f"id={self.get_id()}), " + f')') diff --git a/server/trialminigame.py b/server/trialminigame.py index f1a253647..a9eea1d07 100644 --- a/server/trialminigame.py +++ b/server/trialminigame.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -21,69 +22,1811 @@ """ +from __future__ import annotations + +import typing +from typing import Callable, Dict, Set, Any, Tuple, Type, Union + import enum -from server.exceptions import GameError, GameWithAreasError -from server.gamewithareas_manager import GameWithAreas +from server.exceptions import HubbedGameError, TrialMinigameError +from server.hubbedgame_manager import _HubbedGame + +if typing.TYPE_CHECKING: + # Avoid circular referencing + from server.area_manager import AreaManager + from server.client_manager import ClientManager + from server.game_manager import _Team + from server.hub_manager import _Hub + from server.hubbedgame_manager import HubbedGameManager + from server.timer_manager import Timer + from server.trial_manager import _Trial + from server.tsuserver import TsuserverDR + +class TRIALMINIGAMES(enum.Enum): + """ + All supported trial minigames. + """ + + NONSTOP_DEBATE = enum.auto() + +class _TrialMinigameTrivialInherited(_HubbedGame): + """ + This class should not be instantiated. + """ + + def get_id(self) -> str: + """ + Return the ID of this trial minigame. + + Returns + ------- + str + The ID. + + """ + + return super().get_id() + + def get_numerical_id(self) -> int: + """ + Return the numerical portion of the ID of this trial minigame. + + Returns + ------- + int + Numerical portion of the ID. + """ + + return super().get_numerical_id() + + def get_name(self) -> str: + """ + Get the name of the trial minigame. + + Returns + ------- + str + Name. + """ + + return super().get_name() + + def set_name(self, name: str): + """ + Set the name of the trial minigame. + + Parameters + ---------- + name : str + Name. + """ + + self.unchecked_set_name(name) + self.manager._check_structure() + + def unchecked_set_name(self, name: str): + """ + Set the name of the trial minigame. + + This method does not assert structural integrity. + + Parameters + ---------- + name : str + Name. + """ + + super().unchecked_set_name(name) + + def get_player_limit(self) -> Union[int, None]: + """ + Return the player membership limit of this trial minigame. + + Returns + ------- + Union[int, None] + The player membership limit. + + """ + + return super().get_player_limit() + + def get_player_concurrent_limit(self) -> Union[int, None]: + """ + Return the concurrent player membership limit of this trial minigame. + + Returns + ------- + Union[int, None] + The concurrent player membership limit. + + """ + + return super().get_player_concurrent_limit() + + def get_players( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of players of this trial minigame that satisfy a + condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all players returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) players of this trial minigame. + + """ + + return super().get_players(cond=cond) + + def is_player(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is a player of the trial minigame. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Returns + ------- + bool + True if the user is a player, False otherwise. + + """ + + return super().is_player(user) + + def add_player(self, user: ClientManager.Client): + """ + Make a user a player of the trial minigame. By default this player will not be a leader, + unless the trial minigame has no leaders and it requires a leader. + It will also subscribe the trial minigame to the player so it can listen to its updates. + + Newly added players will be ordered to switch to a 'trial minigame' variant. + + Parameters + ---------- + user : ClientManager.Client + User to add to the trial minigame. They must be in an area part of the trial minigame. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.UserNotInAreaError + If the user is not in an area part of the trial minigame. + TrialMinigameError.UserHasNoCharacterError + If the user has no character but the trial minigame requires that all players have + characters. + TrialMinigameError.UserNotInvitedError + If the trial minigame requires players be invited to be added and the user is not + invited. + TrialMinigameError.UserAlreadyPlayerError + If the user to add is already a user of the trial minigame. + TrialMinigameError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the trial minigame + managed by the manager of this trial minigame, or by virtue of joining this + trial minigame they would violate this trial minigame's concurrent player membership + limit. + TrialMinigameError.GameIsFullError + If the trial minigame reached its player limit. + + """ + + self.unchecked_add_player(user) + self.manager._check_structure() + + def remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this trial minigame. If they were part of a team + managed by this trial minigame, they will also be removed from said team. It will also + unsubscribe the trial minigame from the player so it will no longer listen to its updates. + + If the trial minigame required that there it always had players and by calling this method + the trial minigame had no more players, the trial minigame will automatically be scheduled + for deletion. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.UserNotPlayerError + If the user to remove is already not a player of this trial minigame. + + """ + + self.unchecked_remove_player(user) + self.manager._check_structure() + + def unchecked_remove_player(self, user: ClientManager.Client): + """ + Make a user be no longer a player of this trial minigame. If they were part of a team + managed by this trial minigame, they will also be removed from said team. It will also + unsubscribe the trial minigame from the player so it will no longer listen to its updates. + + If the trial minigame required that there it always had players and by calling this method + the trial minigame had no more players, the trial minigame will automatically be scheduled + for deletion. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to remove. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.UserNotPlayerError + If the user to remove is already not a player of this trial minigame. + + """ + + try: + super().unchecked_remove_player(user) + except HubbedGameError.GameIsUnmanagedError: + raise TrialMinigameError.GameIsUnmanagedError + except HubbedGameError.UserNotPlayerError: + raise TrialMinigameError.UserNotPlayerError + + def requires_players(self) -> bool: + """ + Return whether the trial minigame requires players at all times. + + Returns + ------- + bool + Whether the trial minigame requires players at all times. + """ + + return super().requires_players() + + def get_invitations( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of invited users of this trial minigame that satisfy a + condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all invited users returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) invited users of this trial minigame. + + """ + + return super().get_invitations(cond=cond) + + def is_invited(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is invited to the trial minigame. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Raises + ------ + TrialMinigameError.UserAlreadyPlayerError + If the user is a player of this trial minigame. + + Returns + ------- + bool + True if the user is invited, False otherwise. + + """ + + try: + return super().is_invited(user) + except HubbedGameError.UserAlreadyPlayerError: + raise TrialMinigameError.UserAlreadyPlayerError + + def add_invitation(self, user: ClientManager.Client): + """ + Mark a user as invited to this trial minigame. + + Parameters + ---------- + user : ClientManager.Client + User to invite to the trial minigame. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameDoesNotTakeInvitationsError + If the trial minigame does not require users be invited to the trial minigame. + TrialMinigameError.UserAlreadyInvitedError + If the player to invite is already invited to the trial minigame. + TrialMinigameError.UserAlreadyPlayerError + If the player to invite is already a player of the trial minigame. + + """ + + self.unchecked_add_invitation(user) + self.manager._check_structure() + + def unchecked_add_invitation(self, user: ClientManager.Client): + """ + Mark a user as invited to this trial minigame. + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to invite to the trial minigame. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameDoesNotTakeInvitationsError + If the trial minigame does not require users be invited to the trial minigame. + TrialMinigameError.UserAlreadyInvitedError + If the player to invite is already invited to the trial minigame. + TrialMinigameError.UserAlreadyPlayerError + If the player to invite is already a player of the trial minigame. + + """ + + try: + super().unchecked_add_invitation(user) + except HubbedGameError.GameIsUnmanagedError: + raise TrialMinigameError.GameIsUnmanagedError + except HubbedGameError.GameDoesNotTakeInvitationsError: + raise TrialMinigameError.GameDoesNotTakeInvitationsError + except HubbedGameError.UserAlreadyInvitedError: + raise TrialMinigameError.UserAlreadyInvitedError + except HubbedGameError.UserAlreadyPlayerError: + raise TrialMinigameError.UserAlreadyPlayerError + + def remove_invitation(self, user: ClientManager.Client): + """ + Mark a user as no longer invited to this trial minigame (uninvite). + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameDoesNotTakeInvitationsError + If the trial minigame does not require users be invited to the trial minigame. + TrialMinigameError.UserNotInvitedError + If the user to uninvite is already not invited to this trial minigame. + + """ + + self.unchecked_remove_invitation(user) + self.manager._check_structure() + + def unchecked_remove_invitation(self, user: ClientManager.Client): + """ + Mark a user as no longer invited to this trial minigame (uninvite). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to uninvite. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameDoesNotTakeInvitationsError + If the trial minigame does not require users be invited to the trial minigame. + TrialMinigameError.UserNotInvitedError + If the user to uninvite is already not invited to this trial minigame. + + """ + + try: + super().unchecked_remove_invitation(user) + except HubbedGameError.GameIsUnmanagedError: + raise TrialMinigameError.GameIsUnmanagedError + except HubbedGameError.GameDoesNotTakeInvitationsError: + raise TrialMinigameError.GameDoesNotTakeInvitationsError + except HubbedGameError.UserNotInvitedError: + raise TrialMinigameError.UserNotInvitedError + + def requires_invitations(self): + """ + Return True if the trial minigame requires players be invited before being allowed to join + the trial minigame, False otherwise. + + Returns + ------- + bool + True if the trial minigame requires players be invited before being allowed to join + the trial minigame, False otherwise. + """ + + return super().requires_invitations() + + def get_leaders( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of leaders of this trial minigame that satisfy a condition + if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all leaders returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) leaders of this trial minigame. + + """ + + return super().get_leaders(cond=cond) + + def get_regulars( + self, + cond: Callable[[ClientManager.Client, ], bool] = None + ) -> Set[ClientManager.Client]: + """ + Return (a shallow copy of) the set of players of this trial minigame that are regulars and + satisfy a condition if given. + + Parameters + ---------- + cond : Callable[[ClientManager.Client, ], bool], optional + Condition that all regulars returned satisfy. Defaults to None (no checked + conditions). + + Returns + ------- + Set[ClientManager.Client] + The (filtered) regulars of this trial minigame. + + """ + + return super().get_regulars(cond=cond) + + def is_leader(self, user: ClientManager.Client) -> bool: + """ + Decide if a user is a leader of the trial minigame. + + Parameters + ---------- + user : ClientManager.Client + User to test. + + Raises + ------ + TrialMinigameError.UserNotPlayerError + If the player to test is not a player of this trial minigame. + + Returns + ------- + bool + True if the player is a user, False otherwise. + + """ + + try: + return super().is_leader(user) + except HubbedGameError.UserNotPlayerError: + raise TrialMinigameError.UserNotPlayerError + + def add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this trial minigame (promote to leader). + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.UserNotPlayerError + If the player to promote is not a player of this trial minigame. + TrialMinigameError.UserAlreadyLeaderError + If the player to promote is already a leader of this trial minigame. + + """ + + self.unchecked_add_leader(user) + self.manager._check_structure() + + def unchecked_add_leader(self, user: ClientManager.Client): + """ + Set a user as leader of this trial minigame (promote to leader). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + Player to promote to leader. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.UserNotPlayerError + If the player to promote is not a player of this trial minigame. + TrialMinigameError.UserAlreadyLeaderError + If the player to promote is already a leader of this trial minigame. + + """ + + try: + super().unchecked_add_leader(user) + except HubbedGameError.GameIsUnmanagedError: + raise TrialMinigameError.GameIsUnmanagedError + except HubbedGameError.UserNotPlayerError: + raise TrialMinigameError.UserNotPlayerError + except HubbedGameError.UserAlreadyLeaderError: + raise TrialMinigameError.UserAlreadyLeaderError + + def remove_leader(self, user: ClientManager.Client): + """ + Make a user no longer leader of this trial minigame (demote). + + Parameters + ---------- + user : ClientManager.Client + User to demote. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.UserNotPlayerError + If the player to demote is not a player of this trial minigame. + TrialMinigameError.UserNotLeaderError + If the player to demote is already not a leader of this trial minigame. + + """ + + self.unchecked_remove_leader(user) + self.manager._check_structure() + + def unchecked_remove_leader(self, user: ClientManager.Client): + """ + Make a user no longer leader of this trial minigame (demote). + + This method does not assert structural integrity. + + Parameters + ---------- + user : ClientManager.Client + User to demote. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.UserNotPlayerError + If the player to demote is not a player of this trial minigame. + TrialMinigameError.UserNotLeaderError + If the player to demote is already not a leader of this trial minigame. + + """ + + try: + super().unchecked_remove_leader(user) + except HubbedGameError.GameIsUnmanagedError: + raise TrialMinigameError.GameIsUnmanagedError + except HubbedGameError.UserNotPlayerError: + raise TrialMinigameError.UserNotPlayerError + except HubbedGameError.UserNotLeaderError: + raise TrialMinigameError.UserNotLeaderError + + def has_ever_had_players(self) -> bool: + """ + Return True if a player has ever been added to this trial minigame, False otherwise. + + Returns + ------- + bool + True if the trial minigame has ever had a player added, False otherwise. + + """ + + return super().has_ever_had_players() + + def requires_leaders(self) -> bool: + """ + Return whether the trial minigame requires leaders at all times. + + Returns + ------- + bool + Whether the trial minigame requires leaders at all times. + """ + + return super().requires_leaders() + + def requires_participant_characters(self) -> bool: + """ + Return whether the trial minigame requires players have a participant character at all times. + + Returns + ------- + bool + Whether the trial minigame requires players have a participant character at all times. + """ + + return super().requires_participant_characters() + + def new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this trial minigame with given parameters. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the trial minigame will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameTooManyTimersError + If the trial minigame is already managing its maximum number of timers. + + """ + + timer = self.unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + self.manager._check_structure() + return timer + + def unchecked_new_timer( + self, + timer_type: Type[Timer] = None, + start_value: Union[float, None] = None, + tick_rate: float = 1, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + auto_restart: bool = False, + auto_destroy: bool = True + ) -> Timer: + """ + Create a new timer managed by this trial minigame with given parameters. + + This method does not assert structural integrity. + + Parameters + ---------- + timer_type : Type[Timer], optional + Class of timer that will be produced. Defaults to None (and converted to Timer). + start_value : float, optional + Number of seconds the apparent timer the timer will initially have. Defaults + to None (will use the default from `timer_type`). + tick_rate : float, optional + Starting rate in timer seconds/IRL seconds at which the timer will tick. Defaults to 1. + min_value : float, optional + Minimum value the apparent timer may take. If the timer ticks below this, it will + end automatically. It must be a non-negative number. Defaults to None (will use the + default from `timer_type`.) + max_value : float, optional + Maximum value the apparent timer may take. If the timer ticks above this, it will + end automatically. Defaults to None (will use the default from `timer_type`). + auto_restart : bool, optional + If True, the timer will reset without terminating back to its max value if the tick rate + was non-negative and the timer went below its min value, or back to its max value if + the tick rate was negative and the timer went above its max value. If False, the + timer will terminate once either of the two conditions is satisfied without restarting. + Defaults to False. + auto_destroy : bool, optional + If True, the trial minigame will automatically delete the timer once it is terminated + by it ticking out or manual termination. If False, no such automatic deletion will take + place. Defaults to True. + + Returns + ------- + Timer + The created timer. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameTooManyTimersError + If the trial minigame is already managing its maximum number of timers. + + """ + + try: + return super().unchecked_new_timer( + timer_type=timer_type, + start_value=start_value, + tick_rate=tick_rate, + min_value=min_value, + max_value=max_value, + auto_restart=auto_restart, + auto_destroy=auto_destroy, + ) + except HubbedGameError.GameIsUnmanagedError: + raise TrialMinigameError.GameIsUnmanagedError + except HubbedGameError.GameTooManyTimersError: + raise TrialMinigameError.GameTooManyTimersError + + def delete_timer(self, timer: Timer) -> str: + """ + Delete a timer managed by this trial minigame, terminating it first if needed. + + Parameters + ---------- + timer : Timer + The timer to delete. + + Returns + ------- + str + The ID of the timer that was deleted. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameDoesNotManageTimerError + If the trial minigame does not manage the target timer. + + """ + + timer_id = self.unchecked_delete_timer(timer) + self.manager._check_structure() + return timer_id + + def unchecked_delete_timer(self, timer: Timer) -> str: + """ + Delete a timer managed by this trial minigame, terminating it first if needed. + + This method does not assert structural integrity. + + Parameters + ---------- + timer : Timer + The timer to delete. + + Returns + ------- + str + The ID of the timer that was deleted. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameDoesNotManageTimerError + If the trial minigame does not manage the target timer. + + """ + + try: + return super().unchecked_delete_timer(timer) + except HubbedGameError.GameIsUnmanagedError: + raise TrialMinigameError.GameIsUnmanagedError + except HubbedGameError.GameDoesNotManageTimerError: + raise TrialMinigameError.GameDoesNotManageTimerError + + def get_timers(self) -> Set[Timer]: + """ + Return (a shallow copy of) the timers this trial minigame manages. + + Returns + ------- + Set[Timer] + Timers this trial minigame manages. + + """ + + return super().get_timers() + + def get_timer_by_id(self, timer_id: str) -> Timer: + """ + If `timer_tag` is the ID of a timer managed by this trial minigame, return that timer. + + Parameters + ---------- + timer_id: str + ID of timer this trial minigame manages. + + Returns + ------- + Timer + The timer whose ID matches the given ID. + + Raises + ------ + TrialMinigameError.GameInvalidTimerIDError: + If `timer_tag` is a str and it is not the ID of a timer this trial minigame manages. + + """ + + try: + return super().get_timer_by_id(timer_id) + except HubbedGameError.GameInvalidTimerIDError: + raise TrialMinigameError.GameInvalidTimerIDError + + def get_timer_limit(self) -> Union[int, None]: + """ + Return the timer limit of this trial minigame. + + Returns + ------- + Union[int, None] + Timer limit. + + """ + + return super().get_timer_limit() + + def get_timer_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all timers managed by this trial minigame. + + Returns + ------- + Set[str] + The IDs of all managed timers. + + """ + + return super().get_timer_ids() + + def new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this trial minigame. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this trial minigame. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + + Returns + ------- + _Team + The created team. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameTooManyTeamsError + If the trial minigame is already managing its maximum number of teams. + TrialMinigameError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this trial minigame. + + """ + + team = self.unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + self.manager._check_structure() + return team + + def unchecked_new_team( + self, + team_type: Type[_Team], + creator: ClientManager.Client = None, + player_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True + ) -> _Team: + """ + Create a new team managed by this trial minigame. + + This method does not assert structural integrity. + + Parameters + ---------- + team_type : _Team + Class of team that will be produced. Defaults to None (and converted to the + default team created by games, namely, _Team). + creator : ClientManager.Client, optional + The player who created this team. If set, they will also be added to the team if + possible. The creator must be a player of this trial minigame. Defaults to None. + player_limit : int, optional + The maximum number of players the team may have. Defaults to None (no limit). + require_invitations : bool, optional + If True, users can only be added to the team if they were previously invited. If + False, no checking for invitations is performed. Defaults to False. + require_players : bool, optional + If True, if at any point the team has no players left, the team will automatically + be deleted. If False, no such automatic deletion will happen. Defaults to True. + require_leaders : bool, optional + If True, if at any point the team has no leaders left, the team will choose a + leader among any remaining players left; if no players are left, the next player + added will be made leader. If False, no such automatic assignment will happen. + Defaults to True. + + Returns + ------- + _Team + The created team. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameTooManyTeamsError + If the trial minigame is already managing its maximum number of teams. + TrialMinigameError.UserInAnotherTeamError + If `creator` is not None and already part of a team managed by this trial minigame. + + """ + + try: + return super().unchecked_new_team( + team_type=team_type, + creator=creator, + player_limit=player_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + ) + except HubbedGameError.GameIsUnmanagedError: + raise TrialMinigameError.GameIsUnmanagedError + except HubbedGameError.GameTooManyTeamsError: + raise TrialMinigameError.GameTooManyTeamsError + except HubbedGameError.UserInAnotherTeamError: + raise TrialMinigameError.UserInAnotherTeamError + + def delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this trial minigame. + + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameDoesNotManageTeamError + If the trial minigame does not manage the target team. + + """ + + team_id, players = self.unchecked_delete_team(team) + self.manager._check_structure() + return team_id, players + + def unchecked_delete_team(self, team: _Team) -> Tuple[str, Set[ClientManager.Client]]: + """ + Delete a team managed by this trial minigame. + + This method does not assert structural integrity. + + Parameters + ---------- + team : _Team + The team to delete. + + Returns + ------- + Tuple[str, Set[ClientManager.Client]] + The ID and players of the team that was deleted. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.GameDoesNotManageTeamError + If the trial minigame does not manage the target team. + + """ + + try: + return super().unchecked_delete_team(team) + except HubbedGameError.GameIsUnmanagedError: + raise TrialMinigameError.GameIsUnmanagedError + except HubbedGameError.GameDoesNotManageTeamError: + raise TrialMinigameError.GameDoesNotManageTeamError + + def manages_team(self, team: _Team) -> bool: + """ + Return True if the team is managed by this trial minigame, False otherwise. + + Parameters + ---------- + team : _Team + The team to check. + + Returns + ------- + bool + True if the trial minigame manages this team, False otherwise. + + """ + + return super().manages_team(team) + + def get_teams(self) -> Set[_Team]: + """ + Return (a shallow copy of) the teams this trial minigame manages. + + Returns + ------- + Set[_Team] + Teams this trial minigame manages. + + """ + + return super().get_teams() + + def get_team_by_id(self, team_id: str) -> _Team: + """ + If `team_id` is the ID of a team managed by this trial minigame, return the team. + + Parameters + ---------- + team_id : str + ID of the team this trial minigame manages. + + Returns + ------- + _Team + The team that matches the given ID. + + Raises + ------ + TrialMinigameError.GameInvalidTeamIDError: + If `team_id` is not the ID of a team this trial minigame manages. + + """ + + try: + return super().get_team_by_id(team_id) + except HubbedGameError.GameInvalidTeamIDError: + raise TrialMinigameError.GameInvalidTeamIDError + + def get_team_limit(self) -> Union[int, None]: + """ + Return the team limit of this trial minigame. + + Returns + ------- + Union[int, None] + Team limit. + + """ + + return super().get_team_limit() + + def get_team_ids(self) -> Set[str]: + """ + Return (a shallow copy of) the IDs of all teams managed by this trial minigame. + + Returns + ------- + Set[str] + The IDs of all managed teams. + + """ + + return super().get_team_ids() + + def get_teams_of_user(self, user: ClientManager.Client) -> Set[_Team]: + """ + Return (a shallow copy of) the teams managed by this trial minigame user `user` is a player + of. If the user is part of no such team, an empty set is returned. + + Parameters + ---------- + user : ClientManager.Client + User whose teams will be returned. + + Returns + ------- + Set[_Team] + Teams the player belongs to. + + """ + + return super().get_teams_of_user(user) + + def get_users_in_some_team(self): + """ + Return (a shallow copy of) all the users that are part of some team managed by this + trial minigame. + + Returns + ------- + Set[ClientManager.Client] + Users in some managed team. + + """ + + return super().get_users_in_some_team() + + def get_available_team_id(self) -> str: + """ + Get a team ID that no other team managed by this team has. + + Returns + ------- + str + A unique team ID. + + Raises + ------ + TrialMinigameError.GameTooManyTeamsError + If the trial minigame is already managing its maximum number of teams. + + """ + + try: + return super().get_available_team_id() + except HubbedGameError.GameTooManyTeamsError: + raise TrialMinigameError.GameTooManyTeamsError + + def get_autoadd_on_client_enter(self) -> bool: + """ + Return True if the trial minigame will always attempt to add nonplayer users who enter an + area part of the trial minigame, False otherwise. + + Returns + ------- + bool + True if the trial minigame will always attempt to add nonplayer users who enter an area + part of the trial minigame, False otherwise. + """ + + return super().get_autoadd_on_client_enter() + + def set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + Parameters + ---------- + new_value : bool + New value. + + """ + + self.unchecked_set_autoadd_on_client_enter(new_value) + self.manager._check_structure() + + def unchecked_set_autoadd_on_client_enter(self, new_value: bool): + """ + Set the new status of the autoadd on client enter flag. + + This method does not assert structural integrity. + + Parameters + ---------- + new_value : bool + New value. + + """ + + super().unchecked_set_autoadd_on_client_enter(new_value) + + def add_area(self, area: AreaManager.Area): + """ + Add an area to this trial minigame's set of areas. + + Parameters + ---------- + area : AreaManager.Area + Area to add. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.AreaDisallowsBulletsError + If the area to add disallows bullets. + TrialMinigameError.AreaAlreadyInGameError + If the area is already part of the trial minigame. + TrialMinigameError.AreaHitGameConcurrentLimitError. + If `area` has reached the concurrent area membership limit of any of the games with + areas it belongs to managed by this manager, or by virtue of adding this area it will + violate this trial minigame's concurrent area membership limit. + + """ + + self.unchecked_add_area(area) + self.manager._check_structure() + def remove_area(self, area: AreaManager.Area): + """ + Remove an area from this trial minigame's set of areas. + If the area is already a part of the trial minigame, do nothing. + If any player of the trial minigame is in this area, they are removed from the + trial minigame. + If the trial minigame has no areas remaining, it will be automatically destroyed. -class TRIALMINIGAMES(enum.Enum): - """ - All supported trial minigames. - """ + Parameters + ---------- + area : AreaManager.Area + Area to remove. - NONSTOP_DEBATE = enum.auto() + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.AreaNotInGameError + If the area is already not part of the trial minigame. + + """ + + self.unchecked_remove_area(area) + self.manager._check_structure() + + def unchecked_remove_area(self, area: AreaManager.Area): + """ + Remove an area from this trial minigame's set of areas. + If the area is already a part of the trial minigame, do nothing. + If any player of the trial minigame is in this area, they are removed from the + trial minigame. + If the trial minigame has no areas remaining, it will be automatically destroyed. + + This method does not assert structural integrity. + + Parameters + ---------- + area : AreaManager.Area + Area to remove. + + Raises + ------ + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator + public method calls. + TrialMinigameError.AreaNotInGameError + If the area is already not part of the trial minigame. + + """ + + try: + super().unchecked_remove_area(area) + except HubbedGameError.GameIsUnmanagedError: + raise TrialMinigameError.GameIsUnmanagedError + except HubbedGameError.AreaNotInGameError: + raise TrialMinigameError.AreaNotInGameError + + def requires_areas(self) -> bool: + """ + Return whether the trial minigame requires areas at all times. + + Returns + ------- + bool + Whether the trial minigame requires areas at all times. + """ + + return super().requires_areas() + + def has_area(self, area: AreaManager.Area) -> bool: + """ + If the area is part of this trial minigame's set of areas, return True; otherwise, return + False. + + Parameters + ---------- + area : AreaManager.Area + Area to check. + + Returns + ------- + bool + True if the area is part of the trial minigame's set of areas, False otherwise. + + """ + + return super().has_area(area) + + def get_areas(self) -> Set[AreaManager.Area]: + """ + Return (a shallow copy of) the set of areas of this trial minigame. + + Returns + ------- + Set[AreaManager.Area] + Set of areas of the trial minigame. + + """ + + return super().get_areas() + + def get_area_concurrent_limit(self) -> Union[int, None]: + """ + Return the concurrent area membership limit of this trial minigame. + + Returns + ------- + Union[int, None] + The concurrent area membership limit. + + """ + + return super().get_area_concurrent_limit() + + def get_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the trial minigame, even those that are not players of + the trial minigame. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the trial minigame. + + """ + + return super().get_users_in_areas() + + def get_nonleader_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the trial minigame, even those that are not players of + the trial minigame, such that they are not leaders of the trial minigame. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the trial minigame that are not leaders of the trial minigame. + + """ + + return super().get_nonleader_users_in_areas() + + def get_nonplayer_users_in_areas(self) -> Set[ClientManager.Client]: + """ + Return all users in areas part of the trial minigame that are not players of the + trial minigame. + + Returns + ------- + Set[ClientManager.Client] + All users in areas part of the trial minigame that are not players of the trial minigame. + + """ + + return super().get_nonplayer_users_in_areas() + + def is_unmanaged(self): + """ + Return True if this trial minigame is unmanaged, False otherwise. + + Returns + ------- + bool + True if unmanaged, False otherwise. + + """ + + return super().is_unmanaged() + + def destroy(self): + """ + Mark this trial minigame as destroyed and notify its manager so that it is deleted. + If the trial minigame is already destroyed, this function does nothing. + A trial minigame marked for destruction will delete all of its timers, teams, remove all + its players and unsubscribe it from updates of its former players. + + This method is reentrant (it will do nothing though). + + Returns + ------- + None. + + """ + + self.unchecked_destroy() + self.manager._check_structure() + self._check_structure() # Manager will not check this otherwise. + + def _on_client_inbound_ms_check( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): + """ + Default callback for trial minigame player signaling it wants to check if sending an IC + message is appropriate. The IC arguments can be passed by reference, so this also serves as + an opportunity to modify the IC message if neeeded. + + To indicate a message should not be sent, some TsuserverException can be raised. The + message of the exception will be sent to the client. + + Parameters + ---------- + player : ClientManager.Client + Player that wants to send the IC message. + contents : Dict[str, Any], optional + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_client_inbound_ms_check(player, contents=contents) + + def _on_client_inbound_ms_final( + self, + player: ClientManager.Client, + contents: Dict[str, Any] = None + ): + """ + Default callback for trial minigame player signaling it has sent an IC message. + This callback is executed after the server is done making all modifications to the MS packet + sent by the server. + + By default does nothing. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has sent an IC message. + contents : Dict[str, Any], optional + Arguments of the IC message as indicated in AOProtocol. + Returns + ------- + None. + + """ + + super()._on_client_inbound_ms_final(player, contents=contents) + + def _on_client_change_character( + self, + player: ClientManager.Client, + old_char_id: int = -1, + old_char_name: str = '', + new_char_id: int = -1, + new_char_name: str = '', + ): + """ + Default callback for trial minigame player signaling it has changed character. + + By default it only checks if the player is now no longer having a participant character. If + that is the case and the trial minigame requires all players have participant characters, + the player is automatically removed. + + Note that it may not necessarily be the case that the following hold: + 1. `old_char_name == player.hub.character_manager.get_character_name(old_char_id)`. + 2. `new_char_name == player.hub.character_manager.get_character_name(new_char_id)`. + This can occur for example if the character list changes, which prompts the player to + change character. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it has changed character. + old_char_id : int, optional + Previous character ID. The default is -1. + old_char_name : str, optional + Previous character name. The default is the empty string. + new_char_id : int, optional + New character ID. The default is -1. + new_char_name : int, optional + New character name. The default is the empty string. + + Returns + ------- + None. + + """ + + super()._on_client_change_character( + player, + old_char_id=old_char_id, + old_char_name=old_char_name, + new_char_id=new_char_id, + new_char_name=new_char_name, + ) + + def _on_client_destroyed(self, player: ClientManager.Client): + """ + Default callback for trial minigame player signaling it was destroyed, for example, as a + result of a disconnection. + + By default it only removes the player from the trial minigame If the trial minigame is + already unmanaged or the player is not in the trial minigame, this callback does nothing. + + Parameters + ---------- + player : ClientManager.Client + Player that signaled it was destroyed. + + Returns + ------- + None. + + """ + + super()._on_client_destroyed(player) + + def _on_area_client_inbound_ms_check( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + contents: Dict[str, Any] = None + ): + """ + Default callback for trial minigame area signaling a client in the area sent an IC message. + Unlike the ClientManager.Client callback for send_ic_check, this one is triggered + regardless of whether the sender is part of the trial minigame or not. This is useful for + example, to filter out messages sent by non-players. + + By default does nothing. + + Parameters + ---------- + area : AreaManager.Area + Area that signaled a client has entered. + client : ClientManager.Client, optional + The client that has send the IC message. The default is None. + contents : Dict[str, Any] + Arguments of the IC message as indicated in AOProtocol. + + Returns + ------- + None. + + """ + + super()._on_area_client_inbound_ms_check(area, client=client, contents=contents) + + def _on_area_destroyed(self, area: AreaManager.Area): + """ + Default callback for trial minigame area signaling it was destroyed. + + By default it calls self.unchecked_remove_area(area). + + Parameters + ---------- + area : AreaManager.Area + Area that signaled it was destroyed. + + Returns + ------- + None. + + """ + + super()._on_area_destroyed(area) + + def _on_areas_loaded(self, area_manager: AreaManager): + """ + Default callback for hub area manager signaling it loaded new areas. + + By default it calls self.destroy(). + + Parameters + ---------- + area_manager : AreaManager + AreaManager that signaled the areas load. + + Returns + ------- + None. + + """ + + super()._on_areas_loaded(area_manager) -class TrialMinigame(GameWithAreas): + +class _TrialMinigame(_TrialMinigameTrivialInherited): """ - A trial minigame is a game with areas that is part of a trial. Any players of the minigame - must be players of the trial, and any areas of the minigame must be areas of the trial. - Each of these minigames may also set an autoadd on trial adding player flag. If set, if the - parent trial adds a player, they will also be added to the game if possible; if this fails, - no action is taken and no errors are propagated. + A trial minigame is a hubbed game that is part of a trial. Any players of the trial minigame + must be players of the trial, and any areas of the trial minigame must be areas of the trial. + + Each of these trial minigames may also set an autoadd on trial adding player flag. If set, if + the parent trial adds a player, they will also be added to the trial minigame if possible; if + this fails, no action is taken and no errors are propagated. Attributes ---------- + server : TsuserverDR + Server the trial minigame belongs to. + manager : HubbedGameManager + Manager for this trial minigame. + hub: _Hub + Hub for this hubbed game. listener : Listener - Standard listener of the minigame. + Standard listener of the trial minigame. Callback Methods ---------------- - _on_area_client_left_final - Method to perform once a client left an area of the minigame. - _on_area_client_entered_final - Method to perform once a client entered an area of the minigame. - _on_area_destroyed - Method to perform once an area of the minigame is marked for destruction. _on_client_inbound_ms_check - Method to perform once a player of the minigame wants to send an IC message. + Method to perform once a player of the trial minigame wants to send an IC message. _on_client_inbound_ms_final - Method to perform once a player of the minigame sends an IC message. + Method to perform once a player of the trial minigame sends an IC message. _on_client_change_character - Method to perform once a player of the minigame has changed character. + Method to perform once a player of the trial minigame has changed character. _on_client_destroyed - Method to perform once a player of the minigame is destroyed. + Method to perform once a player of the trial minigame is destroyed. + _on_area_client_left_final + Method to perform once a client left an area of the trial minigame. + _on_area_client_entered_final + Method to perform once a client entered an area of the trial minigame. + _on_area_destroyed + Method to perform once an area of the trial minigame is marked for destruction. """ # (Private) Attributes # -------------------- - # _trial : TrialManager.Trial - # Trial of the minigame - # + # _trial : _Trial + # Trial of the trial minigame + # __autoadd_on_trial_player_add : bool + # Whether players that are added to the trial minigame will be automatically added if + # permitted by the conditions of the trial minigame. + # Invariants # ---------- # 1. The invariants from the parent class TrialMinigame are satisfied. - def __init__(self, server, manager, minigame_id, player_limit=None, - player_concurrent_limit=None, require_invitations=False, require_players=True, - require_leaders=True, require_character=False, team_limit=None, timer_limit=None, - area_concurrent_limit=1, autoadd_on_client_enter=False, - trial=None, autoadd_on_trial_player_add=False, playergroup_manager=None): + def __init__( + self, + server: TsuserverDR, + manager: HubbedGameManager, + minigame_id: str, + player_limit: Union[int, None] = None, + player_concurrent_limit: Union[int, None] = None, + require_invitations: bool = False, + require_players: bool = True, + require_leaders: bool = True, + require_participant_character: bool = False, + team_limit: Union[int, None] = None, + timer_limit: Union[int, None] = None, + area_concurrent_limit: Union[int, None] = None, + autoadd_on_client_enter: bool = False, + require_areas: bool = True, + hub: _Hub = None, + # new + trial: _Trial = None, + autoadd_on_trial_player_add: bool = False, + ): """ Create a trial minigame. A trial minigame should not be fully initialized anywhere - else other than some manager code, as otherwise the manager will not recognize the minigame. + else other than some manager code, as otherwise the manager will not recognize the + trial minigame. Parameters ---------- @@ -113,12 +1856,12 @@ def __init__(self, server, manager, minigame_id, player_limit=None, choose a leader among any remaining players left; if no players are left, the next player added will be made leader. If False, no such automatic assignment will happen. Defaults to True. - require_character : bool, optional - If False, players without a character will not be allowed to join the trial minigame, - and players that switch to something other than a character will be automatically - removed from the trial minigame. If False, no such checks are made. A player without a - character is considered one where player.has_character() returns False. Defaults to - False. + require_participant_character : bool, optional + If False, players without a participant character will not be allowed to join the + trial minigame, and players that switch to something other than a participant character + will be automatically removed from the trial minigame. If False, no such checks are + made. A player without a participant character is considered one where + player.has_participant_character() returns False. Defaults to False. team_limit : int or None, optional If an int, it is the maximum number of teams the trial minigame supports. If None, it indicates the trial minigame has no team limit. Defaults to None. @@ -126,108 +1869,138 @@ def __init__(self, server, manager, minigame_id, player_limit=None, If an int, it is the maximum number of timers the trial minigame supports. If None, it indicates the trial minigame has no timer limit. Defaults to None. area_concurrent_limit : int or None, optional - If an int, it is the maximum number of trials managed by `manager` that any - area of this trial may belong to, including this trial. If None, it indicates - that this game does not care about how many other trials managed by + If an int, it is the maximum number of trial minigames managed by `manager` that any + area of this trial minigames may belong to, including this trial. If None, it indicates + that this trial minigamedoes not care about how many other trial minigames managed by `manager` each of its areas belongs to. Defaults to 1 (an area may not be a part of - another trial managed by `manager` while being an area of this trial). + another trial minigame managed by `manager` while being an area of this trial). autoadd_on_client_enter : bool, optional - If True, nonplayer users that enter an area part of the game will be automatically - added if permitted by the conditions of the game. If False, no such adding will take - place. Defaults to False. - trial : TrialManager.Trial, optional - Trial the non-stop debate is a part of. + If True, nonplayer users that enter an area part of the trial minigame will be + automatically added if permitted by the conditions of the trial minigame. If False, no + such adding will take place. Defaults to False. + require_areas : bool, optional + If True, if at any point the trial minigame has no areas left, the game with areas + will automatically be deleted. If False, no such automatic deletion will happen. + Defaults to True. + hub : _Hub, optional + Hub the trial belongs to. Defaults to None. + trial : _Trial, optional + Trial the trial minigame is a part of. autoadd_on_trial_player_add : bool, optional - If True, players that are added to the trial will be automatically added if permitted - by the conditions of the game. If False, no such adding will take place. Defaults to - False. - playergroup_manager : PlayerGroupManager, optional - The internal playergroup manager of the game manager. Access to this value is - limited exclusively to this __init__, and is only to initialize the internal - player group of the trial minigame. - - Raises - ------ - GameError.ManagerTooManyGamesError - If the manager is already managing its maximum number of trial minigames. + If True, players that are added to the trial minigame will be automatically added if + permitted by the conditions of the trial minigame. If False, no such adding will take + place. Defaults to False. """ self._trial = trial self._autoadd_on_trial_player_add = autoadd_on_trial_player_add - super().__init__(server, manager, minigame_id, player_limit=player_limit, - player_concurrent_limit=player_concurrent_limit, - require_invitations=require_invitations, - require_players=require_players, - require_leaders=require_leaders, - require_character=require_character, - team_limit=team_limit, timer_limit=timer_limit, - area_concurrent_limit=area_concurrent_limit, - autoadd_on_client_enter=autoadd_on_client_enter, - playergroup_manager=playergroup_manager) - self.listener.subscribe(trial) + super().__init__( + server, + manager, + minigame_id, + player_limit=player_limit, + player_concurrent_limit=player_concurrent_limit, + require_invitations=require_invitations, + require_players=require_players, + require_leaders=require_leaders, + require_participant_character=require_participant_character, + team_limit=team_limit, + timer_limit=timer_limit, + area_concurrent_limit=area_concurrent_limit, + autoadd_on_client_enter=autoadd_on_client_enter, + require_areas=require_areas, + hub=hub, + ) + self.listener.subscribe(trial) self.listener.update_events({ 'trial_player_added': self._on_trial_player_added, }) - def get_name(self) -> str: + def get_type_name(self) -> str: """ - Return the name of the game. Names are fully lowercase. - Implementations of the class should replace this with a human readable name of the trial. + Return the type name of the trial minigame. Names are fully lowercase. + Implementations of the class should replace this with a human readable name of the trial + minigame. Returns ------- str - Name of the game. + Type name of the trial minigame. """ return "trial minigame" - def add_player(self, user): + def unchecked_add_player(self, user: ClientManager.Client): """ - Make a user a player of the trial minigame. By default this player will not be a leader. - It will also subscribe the game to the player so it can listen to its updates. + Make a user a player of the trial minigame. By default this player will not be a leader, + unless the trial minigame has no leaders and it requires a leader. + It will also subscribe the trial minigame to the player so it can listen to its updates. + + This method does not assert structural integrity. Parameters ---------- user : ClientManager.Client - User to add to the game. They must be in an area part of the game. + User to add to the trial minigame. They must be in an area part of the trial minigame. Raises ------ - GameError.UserNotPlayerError - If the user is not a player of the trial. - GameError.GameIsUnmanagedError - If the minigame was scheduled for deletion and thus does not accept any mutator + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator public method calls. - GameWithAreasError.UserNotInAreaError - If the user is not in an area part of the minigame. - GameError.UserHasNoCharacterError - If the user has no character but the minigame requires that all players have characters. - GameError.UserNotInvitedError - If the minigame requires players be invited to be added and the user is not invited. - GameError.UserAlreadyPlayerError - If the user to add is already a user of the minigame. - GameError.UserHitGameConcurrentLimitError - If the player has reached any of the games it belongs to managed by this minigame's - manager concurrent player membership limit, or by virtue of joining this minigame they - will violate this game's concurrent player membership limit. - GameError.GameIsFullError - If the minigame reached its player limit. + TrialMinigameError.UserNotPlayerError + If the user is not a player of the trial. + TrialMinigameError.UserNotInAreaError + If the user is not in an area part of the trial minigame. + TrialMinigameError.UserHasNoCharacterError + If the user has no character but the trial minigame requires that all players have + characters. + TrialMinigameError.UserNotInvitedError + If the trial minigame requires players be invited to be added and the user is not + invited. + TrialMinigameError.UserAlreadyPlayerError + If the user to add is already a user of the trial minigame. + TrialMinigameError.UserHitGameConcurrentLimitError + If the player has reached the concurrent player membership of any of the trial minigames + managed by the manager of this trial minigame, or by virtue of joining this + trial minigame they would violate this trial minigame's concurrent player membership + limit. + TrialMinigameError.GameIsFullError + If the trial minigame reached its player limit. """ + if self.is_unmanaged(): + raise TrialMinigameError.GameIsUnmanagedError if not self._trial.is_player(user): - raise GameError.UserNotPlayerError - - super().add_player(user) - - def add_area(self, area): + raise TrialMinigameError.UserNotPlayerError + + try: + super().unchecked_add_player(user) + except HubbedGameError.GameIsUnmanagedError: + raise RuntimeError(self) + except HubbedGameError.UserNotInAreaError: + raise TrialMinigameError.UserNotInAreaError + except HubbedGameError.UserHasNoCharacterError: + raise TrialMinigameError.UserHasNoCharacterError + except HubbedGameError.UserNotInvitedError: + raise TrialMinigameError.UserNotInvitedError + except HubbedGameError.UserAlreadyPlayerError: + raise TrialMinigameError.UserAlreadyPlayerError + except HubbedGameError.UserHitGameConcurrentLimitError: + raise TrialMinigameError.UserHitGameConcurrentLimitError + except HubbedGameError.GameIsFullError: + raise TrialMinigameError.GameIsFullError + + def unchecked_add_area(self, area): """ - Add an area to this minigame's set of areas. + Add an area to this trial minigame's set of areas. + + This method does not assert structural integrity. Parameters ---------- @@ -236,46 +2009,51 @@ def add_area(self, area): Raises ------ - GameWithAreasError.AreaNotInGameError - If the area is not part of the trial of the minigame. - GameError.GameIsUnmanagedError - If the minigame was scheduled for deletion and thus does not accept any mutator + TrialMinigameError.GameIsUnmanagedError + If the trial minigame was scheduled for deletion and thus does not accept any mutator public method calls. - GameWithAreasError.AreaAlreadyInGameError - If the area is already part of the minigame. - GameWithAreasError.AreaHitGameConcurrentLimitError. + TrialMinigameError.AreaNotInGameError + If the area is not part of the trial of the trial minigame. + TrialMinigameError.AreaAlreadyInGameError + If the area is already part of the trial minigame. + TrialMinigameError.AreaHitGameConcurrentLimitError. If `area` has reached the concurrent area membership limit of any of the games it belongs to managed by this manager, or by virtue of adding this area it will violate - this game's concurrent area membership limit. - - Returns - ------- - None. + this trial minigame's concurrent area membership limit. """ + if self.is_unmanaged(): + raise TrialMinigameError.GameIsUnmanagedError if not self._trial.has_area(area): - raise GameWithAreasError.AreaNotInGameError - - super().add_area(area) - - def get_trial(self): + raise TrialMinigameError.AreaNotInGameError + + try: + super().unchecked_add_area(area) + except HubbedGameError.GameIsUnmanagedError: + raise RuntimeError(self) + except HubbedGameError.AreaAlreadyInGameError: + raise TrialMinigameError.AreaAlreadyInGameError + except HubbedGameError.AreaHitGameConcurrentLimitError: + raise TrialMinigameError.AreaHitGameConcurrentLimitError + + def get_trial(self) -> _Trial: """ - Return the trial of the game. + Return the trial of the trial minigame. Returns ------- - TrialManager.Trial - Trial of the minigame. + _Trial + Trial of the trial minigame. """ return self._trial - def get_autoadd_on_trial_player_add(self): + def get_autoadd_on_trial_player_add(self) -> bool: """ - Return whether the minigame will attempt to add players to it if the parent trial added it - as player. + Return whether the trial minigame will attempt to add players to it if the parent trial + added it as player. Returns ------- @@ -294,9 +2072,19 @@ def set_autoadd_on_trial_player_add(self, new_value: bool): new_value : bool New value. - Returns - ------- - None. + """ + + self.unchecked_set_autoadd_on_trial_player_add(new_value) + self.manager._check_structure() + + def unchecked_set_autoadd_on_trial_player_add(self, new_value: bool): + """ + Set the new value of the autoadd on trial adding a player flag. + + Parameters + ---------- + new_value : bool + New value. """ @@ -304,25 +2092,27 @@ def set_autoadd_on_trial_player_add(self, new_value: bool): def get_type(self) -> TRIALMINIGAMES: """ - Return the type of the minigame. + Return the type of the trial minigame. Returns ------- TRIALMINIGAMES - Type of minigame. + Type of trial minigame. """ # Should be overriden in child class. raise NotImplementedError - def destroy(self): + def unchecked_destroy(self): """ - Mark this game as destroyed and notify its manager so that it is deleted. - If the game is already destroyed, this function does nothing. + Mark this trial minigameas destroyed and notify its manager so that it is deleted. + If the trial minigameis already destroyed, this function does nothing. This method is reentrant (it will do nothing though). + This method does not assert structural integrity. + Returns ------- None. @@ -333,21 +2123,27 @@ def destroy(self): areas = self.get_areas() # Then carry on - super().destroy() + super().unchecked_destroy() - # Force every user in the former areas of the minigame to switch to trial gamemode + # Force every user in the former areas of the trial minigame to switch to trial gamemode for area in areas: for user in area.clients: user.send_gamemode(name='trial') - def _on_area_client_left_final(self, area, client=None, old_displayname=None, - ignore_bleeding=False, ignore_autopass=False): + def _on_area_client_left_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False, + ): """ - If a player left to an area not part of the minigame, remove the player and warn them and - the leaders of the minigame. + If a player left to an area not part of the trial minigame, remove the player and warn them + and the leaders of the trial minigame. - If a non-plyer left to an area not part of the minigame, warn them and the leaders of the - minigame. + If a non-plyer left to an area not part of the trial minigame, warn them and the leaders of + the trial minigame. Parameters ---------- @@ -378,7 +2174,7 @@ def _on_area_client_left_final(self, area, client=None, old_displayname=None, f'an area not part of your trial minigame and thus was ' f'automatically removed from it ' f'({area.id}->{client.area.id}).', - pred=lambda c: c in self.get_leaders()) + pred=lambda c: c in self.get_leaders(), in_hub=area.hub) self.remove_player(client) if self.is_unmanaged(): @@ -387,7 +2183,7 @@ def _on_area_client_left_final(self, area, client=None, old_displayname=None, f'ended as it lost all its players.') client.send_ooc_others(f'(X) Trial minigame `{self.get_id()}` was automatically ' f'ended as it lost all its players.', - is_zstaff_flex=True) + is_zstaff_flex=True, in_hub=area.hub) elif client.area not in self.get_areas(): client.send_ooc(f'You have left to an area not part of trial minigame ' @@ -395,12 +2191,17 @@ def _on_area_client_left_final(self, area, client=None, old_displayname=None, client.send_ooc_others(f'(X) Player {old_displayname} [{client.id}] has left to an ' f'area not part of your trial minigame ' f'({area.id}->{client.area.id}).', - pred=lambda c: c in self.get_leaders()) - - self._check_structure() - - def _on_area_client_entered_final(self, area, client=None, old_area=None, old_displayname=None, - ignore_bleeding=False, ignore_autopass=False): + pred=lambda c: c in self.get_leaders(), in_hub=area.hub) + + def _on_area_client_entered_final( + self, + area: AreaManager.Area, + client: ClientManager.Client = None, + old_area: Union[AreaManager.Area, None] = None, + old_displayname: str = None, + ignore_bleeding: bool = False, + ignore_autopass: bool = False, + ): """ If a non-player entered, warn them and the leaders of the trial minigame. @@ -411,7 +2212,8 @@ def _on_area_client_entered_final(self, area, client=None, old_area=None, old_di client : ClientManager.Client, optional The client that has entered. The default is None. old_area : AreaManager.Area - The old area the client has come from. The default is None. + The old area the client has come from (possibly None for a newly connected user). The + default is None. old_displayname : str, optional The old displayed name of the client before they changed area. This will typically change only if the client's character or showname are taken. The default is None. @@ -427,25 +2229,31 @@ def _on_area_client_entered_final(self, area, client=None, old_area=None, old_di """ if client not in self.get_players() and old_area not in self.get_areas(): + old_area_id = str(old_area.id) if old_area else "SERVER_SELECT" client.send_ooc(f'You have entered an area part of trial minigame `{self.get_id()}`.') client.send_ooc_others(f'(X) Non-player {client.displayname} [{client.id}] has entered ' f'an area part of your trial minigame ' - f'({old_area.id}->{area.id}).', + f'({old_area_id}->{area.id}).', pred=lambda c: c in self.get_leaders()) - def _on_trial_player_added(self, trial, player=None): + def _on_trial_player_added( + self, + trial: _Trial, + player: ClientManager.Client = None + ): + """ Default callback when the parent trial adds a player. - If a player was added to the trial of the minigame, attempt to add the player to the - minigame as well. If unsuccessful, do nothing. - Do note the player may already be part of the minigame by this point: if another thread - was also listening to this callback and acted upon it before the current thread by adding - the player to the minigame. + If a player was added to the trial of the trial minigame, attempt to add the player to the + trial minigame as well. If unsuccessful, do nothing. + Do note the player may already be part of the trial minigame by this point: if another + thread was also listening to this callback and acted upon it before the current thread by + adding the player to the trial minigame. Parameters ---------- trial : TrialManager._Trial - Trial that generated the callback. Typically is self._trial + Trial that generated the callback. Typically is self.get_trial(). player : ClientManager.Client, optional Player that was added to the trial. @@ -458,12 +2266,12 @@ def _on_trial_player_added(self, trial, player=None): if self.get_autoadd_on_trial_player_add(): try: self.add_player(player) - except GameError: + except TrialMinigameError: pass def __str__(self): """ - Return a string representation of this minigame. + Return a string representation of this trial minigame. Returns ------- @@ -480,7 +2288,7 @@ def __str__(self): def __repr__(self): """ - Return a representation of this game. + Return a representation of this trial minigame. Returns ------- @@ -489,19 +2297,22 @@ def __repr__(self): """ - return (f'TrialMinigame(server, {self._manager.get_id()}, "{self.get_id()}", ' - f'player_limit={self._playergroup._player_limit}, ' + return (f'TrialMinigame(server, {self.manager.get_id()}, "{self.get_id()}", ' + f'player_limit={self.get_player_limit()}, ' f'player_concurrent_limit={self.get_player_concurrent_limit()}, ' - f'require_players={self._playergroup._require_players}, ' - f'require_invitations={self._playergroup._require_invitations}, ' - f'require_leaders={self._playergroup._require_leaders}, ' - f'require_character={self._require_character}, ' - f'team_limit={self._team_manager.get_group_limit()}, ' + f'require_players={self.requires_players()}, ' + f'require_invitations={self.requires_invitations()}, ' + f'require_leaders={self.requires_leaders()}, ' + f'require_participant_character={self.requires_participant_characters()}, ' + f'team_limit={self._team_manager.get_managee_limit()}, ' f'timer_limit={self._timer_manager.get_timer_limit()}, ' f'areas={self.get_areas()}, ' - f'trial={self.get_trial()}) || ' + f'trial_id={self.get_trial().get_id()}), ' + f'|| ' f'players={self.get_players()}, ' f'invitations={self.get_invitations()}, ' f'leaders={self.get_leaders()}, ' f'timers={self.get_timers()}, ' - f'teams={self.get_teams()}') + f'teams={self.get_teams()}, ' + f'unmanaged={self.is_unmanaged()}), ' + f')') diff --git a/server/tsuserver.py b/server/tsuserver.py index 04ad9a95a..ddd7d9591 100644 --- a/server/tsuserver.py +++ b/server/tsuserver.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -20,7 +21,7 @@ # This class will suffer major reworkings for 4.3 from __future__ import annotations -from typing import Any, Callable, Dict, List, Tuple +from typing import Any, Callable, Dict, List, Tuple, Type import asyncio import errno @@ -31,55 +32,46 @@ import ssl import sys import traceback +import typing import urllib.request, urllib.error -import warnings -import yaml from server import logger -from server.area_manager import AreaManager -from server.background_manager import BackgroundManager from server.ban_manager import BanManager -from server.character_manager import CharacterManager from server.constants import Constants from server.client_manager import ClientManager -from server.exceptions import MusicError, ServerError -from server.game_manager import GameManager -from server.music_manager import MusicManager +from server.exceptions import ServerError +from server.hub_manager import HubManager from server.network.ao_protocol import AOProtocol from server.network.ms3_protocol import MasterServerClient from server.party_manager import PartyManager -from server.tasker import Tasker +from server.task_manager import TaskManager from server.timer_manager import TimerManager -from server.trial_manager import TrialManager -from server.zone_manager import ZoneManager from server.validate.config import ValidateConfig from server.validate.gimp import ValidateGimp +if typing.TYPE_CHECKING: + from asyncio.proactor_events import _ProactorSocketTransport + class TsuserverDR: - def __init__(self, protocol: AOProtocol = None, - client_manager: ClientManager = None, in_test: bool = False): + def __init__(self, client_manager_type: Type[ClientManager] = None): + if client_manager_type is None: + client_manager_type = ClientManager + self.logged_packet_limit = 100 # Arbitrary self.logged_packets = [] self.print_packets = False # For debugging purposes self._server = None # Internal server object, changed to proper object later - self.release = 4 - self.major_version = 3 - self.minor_version = 5 - self.segment_version = 'post1' - self.internal_version = '220926a' + self.release = 5 + self.major_version = 0 + self.minor_version = 0 + self.segment_version = '' + self.internal_version = '221123a' version_string = self.get_version_string() self.software = 'TsuserverDR {}'.format(version_string) self.version = 'TsuserverDR {} ({})'.format(version_string, self.internal_version) - self.in_test = in_test - - self.protocol = AOProtocol if protocol is None else protocol - client_manager = ClientManager if client_manager is None else client_manager - logger.log_print = logger.log_print2 if self.in_test else logger.log_print - logger.log_server = logger.log_server2 if self.in_test else logger.log_server - self.random = importlib.reload(random) logger.log_print('Launching {}...'.format(self.version)) logger.log_print('Loading server configurations...') @@ -90,40 +82,29 @@ def __init__(self, protocol: AOProtocol = None, self.shutting_down = False self.loop = None self.last_error = None - self.allowed_iniswaps = None - self.old_area_list = None - self.default_area = 0 self.all_passwords = list() self.global_allowed = True self.server_select_name = 'SERVER_SELECT' self.load_config() - self.timer_manager = TimerManager(self) - self.client_manager: ClientManager = client_manager(self) - self.character_manager = CharacterManager(self) - self.load_iniswaps() - self.load_characters() - - self.game_manager = GameManager(self) - self.trial_manager = TrialManager(self) - self.zone_manager = ZoneManager(self) - self.area_manager = AreaManager(self) - self.background_manager = BackgroundManager(self) - self.music_manager = MusicManager(self) + self.ban_manager = BanManager(self) + self.timer_manager = TimerManager(self) self.party_manager = PartyManager(self) + self.client_manager = client_manager_type(self) + self.hub_manager = HubManager(self) + default_hub = self.hub_manager.new_managee() + default_hub.set_name('Main') + self.ipid_list = {} self.hdid_list = {} self.gimp_list = list() self.load_commandhelp() - self.load_music() - self.load_backgrounds() self.load_ids() self.load_gimp() self.ms_client = None - self.rp_mode = True self.user_auth_req = False self.showname_freeze = False self.commands = importlib.import_module('server.commands') @@ -133,44 +114,17 @@ def __init__(self, protocol: AOProtocol = None, logger.log_print('Server configurations loaded successfully!') self.error_queue = None - with open('config/110_new_music.yaml') as f: - self.new_110_music = set(yaml.load(f, yaml.SafeLoader)) - - self._server = None - - @property - def backgrounds(self): - Constants.warn_deprecated('server.backgrounds', - 'server.background_manager.get_backgrounds()', - '4.4') - return self.background_manager.get_backgrounds() - - @property - def music_list(self): - Constants.warn_deprecated('server.music_list', - 'server.music_manager.get_music()', - '4.4') - return self.music_manager.get_music() - - @property - def area_list(self): - Constants.warn_deprecated('server.area_list', - 'server.area_manager.get_source_file()', - '4.4') - return self.area_manager.get_source_file() async def start(self): self.loop = asyncio.get_event_loop() self.error_queue = asyncio.Queue() - self.tasker = Tasker(self) - bound_ip = '0.0.0.0' + self.task_manager = TaskManager(self) if self.config['local']: bound_ip = '127.0.0.1' - server_name = 'localhost' logger.log_print('Starting a local server...') else: - server_name = self.config['masterserver_name'] + bound_ip = '0.0.0.0' logger.log_print('Starting a nonlocal server...') # Check if port is available @@ -193,7 +147,7 @@ async def start(self): # Yes there is a race condition here (between checking if port is available, and actually # using it). The only side effect of a race condition is a slightly less nice error # message, so it's not that big of a deal. - self._server = await self.loop.create_server(lambda: self.protocol(self), + self._server = await self.loop.create_server(lambda: AOProtocol(self), bound_ip, port, start_serving=False) asyncio.create_task(self._server.serve_forever()) @@ -221,7 +175,7 @@ async def start(self): f'{self.config["port"]}.') if self.config['local']: - self.local_connection = asyncio.create_task(self.tasker.do_nothing()) + self.local_connection = asyncio.create_task(Constants.do_nothing()) if self.config['use_masterserver']: self.ms_client = MasterServerClient(self) @@ -242,12 +196,12 @@ async def normal_shutdown(self): # Cancel further polling for master server if self.local_connection: self.local_connection.cancel() - await self.tasker.await_cancellation(self.local_connection) + await Constants.await_cancellation(self.local_connection) if self.masterserver_connection: self.masterserver_connection.cancel() - await self.tasker.await_cancellation(self.masterserver_connection) - await self.tasker.await_cancellation(self.ms_client.shutdown()) + await Constants.await_cancellation(self.masterserver_connection) + await Constants.await_cancellation(self.ms_client.shutdown()) # Cancel pending client tasks and cleanly remove them from the areas players = self.get_player_count() @@ -267,24 +221,21 @@ def get_version_string(self): mes = '{}-{}'.format(mes, self.segment_version) return mes - def reload(self): - try: - self.background_manager.validate_file() - self.character_manager.validate_file() - self.music_manager.validate_file() - except ServerError.YAMLInvalidError as exc: - # The YAML exception already provides a full description. Just add the fact the - # reload was undone to ease the person who ran the command's nerves. - msg = (f'{exc} Reload was undone.') - raise ServerError.YAMLInvalidError(msg) - except ServerError.FileSyntaxError as exc: - msg = f'{exc} Reload was undone.' - raise ServerError(msg) - - # Only on success reload - self.load_characters() - self.load_backgrounds() - self.load_music() + def check_exec_active(self): + # Determine whether /exec is active or not and warn server owner if so. + if getattr(self.commands, "ooc_cmd_exec")(None, "is_exec_active") == 1: + logger.log_print(""" + + WARNING + + THE /exec COMMAND IN commands.py IS ACTIVE. + + UNLESS YOU ABSOLUTELY MEANT IT AND KNOW WHAT YOU ARE DOING, + PLEASE STOP YOUR SERVER RIGHT NOW AND DEACTIVATE IT BY GOING TO THE + commands.py FILE AND FOLLOWING THE INSTRUCTIONS UNDER ooc_cmd_exec.\n + BAD THINGS CAN AND WILL HAPPEN OTHERWISE. + + """) def reload_commands(self): try: @@ -299,12 +250,18 @@ def log_packet(self, client: ClientManager.Client, packet: str, incoming: bool): entry = ('R:' if incoming else 'S:', Constants.get_time_iso(), str(client.id), packet) self.logged_packets.append(entry) - def new_client(self, transport, protocol=None) -> Tuple[ClientManager.Client, bool]: - c, valid = self.client_manager.new_client(transport, protocol=protocol) - if self.rp_mode: - c.in_rp = True + def new_client( + self, + transport: _ProactorSocketTransport, + protocol: AOProtocol = None, + ) -> Tuple[ClientManager.Client, bool]: + c, valid = self.client_manager.new_client( + hub=self.hub_manager.get_default_managee(), + transport=transport, + protocol=protocol, + ) c.server = self - c.area = self.area_manager.default_area() + c.area = self.hub_manager.get_default_managee().area_manager.default_area() c.area.new_client(c) return c, valid @@ -333,82 +290,6 @@ def get_player_count(self) -> int: # Ignore players in the server selection screen. return len([client for client in self.get_clients() if client.char_id is not None]) - def load_areas(self, source_file: str = 'config/areas.yaml') -> List[AreaManager.Area]: - """ - Load an area list file. - - Parameters - ---------- - source_file : str - Relative path from server root folder to the area list file, by default - 'config/areas.yaml' - - Returns - ------- - List[AreaManager.Area] - Areas. - - Raises - ------ - ServerError.FileNotFoundError - If the file was not found. - ServerError.FileOSError - If there was an operating system error when opening the file. - ServerError.YAMLInvalidError - If the file was empty, had a YAML syntax error, or could not be decoded using UTF-8. - ServerError.FileSyntaxError - If the file failed verification for its asset type. - """ - - areas = self.area_manager.load_file(source_file) - return areas.copy() - - def load_backgrounds(self, source_file: str = 'config/backgrounds.yaml') -> List[str]: - """ - Load a background list file. - - Parameters - ---------- - source_file : str - Relative path from server root folder to background list file, by default - 'config/backgrounds.yaml' - - Returns - ------- - List[str] - Backgrounds. - - Raises - ------ - ServerError.FileNotFoundError - If the file was not found. - ServerError.FileOSError - If there was an operating system error when opening the file. - ServerError.YAMLInvalidError - If the file was empty, had a YAML syntax error, or could not be decoded using UTF-8. - ServerError.FileSyntaxError - If the file failed verification for its asset type. - """ - - old_backgrounds = self.background_manager.get_backgrounds() - backgrounds = self.background_manager.load_file(source_file) - - if old_backgrounds == backgrounds: - # No change implies backgrounds still valid, do nothing more - return backgrounds.copy() - - # Make sure each area still has a valid background - default_background = self.background_manager.get_default_background() - for area in self.area_manager.get_areas(): - if not self.background_manager.is_background(area.background) and not area.cbg_allowed: - # The area no longer has a valid background, so change it to some valid background - # like the first one - area.change_background(default_background) - area.broadcast_ooc(f'After a change in the background list, your area no longer ' - f'had a valid background. Switching to {default_background}.') - - return backgrounds.copy() - def load_config(self) -> Dict[str, Any]: self.config = ValidateConfig().validate('config/config.yaml') @@ -461,68 +342,6 @@ def load_config(self) -> Dict[str, Any]: return self.config - def load_characters(self, source_file: str = 'config/characters.yaml') -> List[str]: - """ - Load a character list file. - - Parameters - ---------- - source_file : str, optional - Relative path from server root folder to character list file, by default - 'config/characters.yaml' - - Returns - ------- - List[str] - Characters. - - Raises - ------ - ServerError.FileNotFoundError - If the file was not found. - ServerError.FileOSError - If there was an operating system error when opening the file. - ServerError.YAMLInvalidError - If the file was empty, had a YAML syntax error, or could not be decoded using UTF-8. - ServerError.FileSyntaxError - If the file failed verification for its asset type. - """ - - old_characters = self.character_manager.get_characters() - characters = self.character_manager.validate_file(source_file) - if old_characters == characters: - return characters.copy() - - # Inconsistent character list, so change to spectator those who lost their character. - new_chars = {char: num for (num, char) in enumerate(characters)} - - for client in self.get_clients(): - target_char_id = -1 - old_char_name = client.get_char_name() - - if not client.has_character(): - # Do nothing for spectators - pass - elif old_char_name not in new_chars: - # Character no longer exists, so switch to spectator - client.send_ooc(f'After a change in the character list, your character is no ' - f'longer available. Switching to {self.config["spectator_name"]}.') - else: - target_char_id = new_chars[old_char_name] - - if client.packet_handler.ALLOWS_CHAR_LIST_RELOAD: - client.send_command_dict('SC', { - 'chars_ao2_list': characters, - }) - client.change_character(target_char_id, force=True) - else: - client.send_ooc('After a change in the character list, your client character list ' - 'is no longer synchronized. Please rejoin the server.') - - # Only now update internally. This is to allow `change_character` to work properly. - self.character_manager.load_file(source_file) - return characters.copy() - def load_commandhelp(self): with Constants.fopen('README.md', 'r', encoding='utf-8') as readme: lines = [x.rstrip() for x in readme.readlines()] @@ -604,7 +423,7 @@ def load_ids(self): self.ipid_list = json.load(whole_list) except ServerError.FileNotFoundError: with Constants.fopen('storage/ip_ids.json', 'w', encoding='utf-8') as whole_list: - json.dump(dict(), whole_list) + json.dump(dict(), whole_list, indent=4) message = 'WARNING: File not found: storage/ip_ids.json. Creating a new one...' logger.log_pdebug(message) except Exception as ex: @@ -620,6 +439,9 @@ def load_ids(self): logger.log_pdebug(message) self.ipid_list = dict() self.dump_ipids() + # TODO: Remove this else and the code within after next major update + else: + self.dump_ipids() # load hdids try: @@ -627,7 +449,7 @@ def load_ids(self): self.hdid_list = json.loads(whole_list.read()) except ServerError.FileNotFoundError: with Constants.fopen('storage/hd_ids.json', 'w', encoding='utf-8') as whole_list: - json.dump(dict(), whole_list) + json.dump(dict(), whole_list, indent=4) message = 'WARNING: File not found: storage/hd_ids.json. Creating a new one...' logger.log_pdebug(message) except Exception as ex: @@ -643,25 +465,9 @@ def load_ids(self): logger.log_pdebug(message) self.hdid_list = dict() self.dump_hdids() - - def load_iniswaps(self): - try: - with Constants.fopen('config/iniswaps.yaml', 'r', encoding='utf-8') as iniswaps: - self.allowed_iniswaps = Constants.yaml_load(iniswaps) - except Exception as ex: - message = 'WARNING: Error loading config/iniswaps.yaml. Will assume empty values.\n' - message += '{}: {}'.format(type(ex).__name__, ex) - - logger.log_pdebug(message) - - def load_music(self, music_list_file: str = 'config/music.yaml', - server_music_list: bool = True) -> List[Dict[str, Any]]: - if server_music_list is not True: - Constants.warn_deprecated('non-default value of server_music_list parameter', - 'server.music_manager.validate_file', - '4.4') - music = self.music_manager.load_file(music_list_file) - return music.copy() + # TODO: Remove this else and the code within after next major update + else: + self.dump_hdids() def load_gimp(self): try: @@ -699,11 +505,11 @@ def load_gimp(self): def dump_ipids(self): with Constants.fopen('storage/ip_ids.json', 'w', encoding='utf-8') as whole_list: - json.dump(self.ipid_list, whole_list) + json.dump(self.ipid_list, whole_list, indent=4) def dump_hdids(self): with Constants.fopen('storage/hd_ids.json', 'w', encoding='utf-8') as whole_list: - json.dump(self.hdid_list, whole_list) + json.dump(self.hdid_list, whole_list, indent=4) def get_ipid(self, ip: str) -> int: if ip not in self.ipid_list: @@ -715,108 +521,6 @@ def get_ipid(self, ip: str) -> int: self.dump_ipids() return self.ipid_list[ip] - def build_music_list(self, from_area: AreaManager.Area = None, c: ClientManager.Client = None, - music_list: List[Dict[str, Any]] = None, include_areas: bool = True, - include_music: bool = True) -> List[str]: - Constants.warn_deprecated('server.build_music_list', - 'client.get_area_and_music_list_view', - '4.4') - built_music_list = list() - - # add areas first, if needed - if include_areas: - built_music_list.extend(self.prepare_area_list(c=c, from_area=from_area)) - - # then add music, if needed - if include_music: - built_music_list.extend(self.prepare_music_list(c=c, specific_music_list=music_list)) - - return built_music_list - - def prepare_area_list(self, c: ClientManager.Client = None, - from_area: AreaManager.Area = None) -> List[str]: - """ - Return the area list of the server. If given c and from_area, it will send an area list - that matches the perspective of client `c` as if they were in area `from_area`. - - Parameters - ---------- - c: ClientManager.Client - Client whose perspective will be taken into account, by default None - from_area: AreaManager.Area - Area from which the perspective will be considered, by default None - - Returns - ------- - list of str - Area list that matches intended perspective. - """ - - Constants.warn_deprecated('server.prepare_area_list', - 'area_manager.get_client_view', - '4.4') - return self.area_manager.get_client_view(c, from_area=from_area) - - def prepare_music_list(self, c: ClientManager.Client = None, - specific_music_list: List[Dict[str, Any]] = None) -> List[str]: - """ - If `specific_music_list` is not None, return a client-ready version of that music list. - Else, return their latest music list. - - Parameters - ---------- - c: ClientManager.Client - Client whose current music list if it exists will be considered if `specific_music_list` - is None - specific_music_list: list of dictionaries with key sets {'category', 'songs'} - Music list to use if given - - Returns - ------- - list of str - Music list ready to be sent to clients - """ - - Constants.warn_deprecated('server.prepare_music_list', - 'client.music_manager.get_client_view', - '4.4') - - if not specific_music_list: - return c.music_manager.get_client_view() - - prepared_music_list = list() - for item in specific_music_list: - category = item['category'] - songs = item['songs'] - prepared_music_list.append(category) - for song in songs: - name = song['name'] - prepared_music_list.append(name) - - return prepared_music_list - - def is_valid_char_id(self, char_id: int) -> bool: - Constants.warn_deprecated('server.is_valid_char_id()', - 'server.character_manager.is_valid_character_id()', - '4.4') - return self.character_manager.is_valid_character_id(char_id) - - def get_char_id_by_name(self, name: str) -> int: - Constants.warn_deprecated('server.get_char_id_by_name()', - 'server.character_manager.get_character_id_by_name()', - '4.4') - return self.character_manager.get_character_id_by_name(name) - - def get_song_data(self, music: str, c: ClientManager.Client = None) -> Tuple[str, int, str]: - Constants.warn_deprecated('server.get_song_data', - 'client.music_manager.get_music_data', - '4.4') - - try: - return c.music_manager.get_music_data(music) - except MusicError.MusicNotFoundError: - raise ServerError.MusicNotFoundError('Music not found.') - def make_all_clients_do(self, function: str, *args: List[str], pred: Callable[[ClientManager.Client], bool] = lambda x: True, **kwargs): @@ -856,7 +560,7 @@ def send_error_report(self, client: ClientManager.Client, cmd: str, args: List[s client.send_ooc(info) client.send_ooc_others('Client {} triggered a Python error through a client packet. ' 'Do /lasterror to take a look at it.'.format(client.id), - pred=lambda c: c.is_mod) + is_mod=True, in_hub=None) # Print complete traceback to console info = 'TSUSERVERDR HAS ENCOUNTERED AN ERROR HANDLING A CLIENT PACKET' @@ -873,12 +577,12 @@ def send_error_report(self, client: ClientManager.Client, cmd: str, args: List[s # Log error to file logger.log_error(info, server=self, errortype='C') - if self.in_test: - raise ex - def broadcast_global(self, client: ClientManager.Client, msg: str, as_mod: bool = False, mtype: str = "G", - condition: Constants.ClientBool = lambda x: not x.muted_global): + condition: Callable[[ClientManager.Client,], bool] = None): + if condition is None: + condition = lambda x: not x.muted_global + username = client.name ooc_name = '{}[{}][{}]'.format(mtype, client.area.id, username) if as_mod: @@ -890,14 +594,3 @@ def broadcast_global(self, client: ClientManager.Client, msg: str, as_mod: bool c.send_ooc(msg, username=ooc_name_ipid) else: c.send_ooc(msg, username=ooc_name) - - def broadcast_need(self, client: ClientManager.Client, msg: str): - char_name = client.displayname - area_name = client.area.name - area_id = client.area.id - - targets = [c for c in self.get_clients() if not c.muted_adverts] - msg = ('=== Advert ===\r\n{} in {} [{}] needs {}\r\n===============' - .format(char_name, area_name, area_id, msg)) - for c in targets: - c.send_ooc(msg) diff --git a/server/validate/areas.py b/server/validate/areas.py index 3f363df81..48c3675f5 100644 --- a/server/validate/areas.py +++ b/server/validate/areas.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -99,7 +100,7 @@ def validate_contents(self, contents, extra_parameters=None): raise ServerError.FileSyntaxError(info) # Prevent conflicts with AO Protocol - if Constants.includes_omniwhy_exploit(item['area']): + if Constants.is_aoprotocol_injection_vulnerable(item['area']): info = (f'Area {item["area"]} contains characters that could cause issues with ' f'certain AO clients, so it is invalid. Please rename the area and try ' f'again.') @@ -176,6 +177,31 @@ def validate_contents(self, contents, extra_parameters=None): new_background_tod[tod_name] = tod_background item['background_tod'] = new_background_tod + # Check afk_sendto and afk_delay (the only mandatory numerical values) + try: + int_afk_sendto = int(item['afk_sendto']) + except (ValueError, TypeError, OverflowError): + info = (f'afk_sendto `{item["afk_sendto"]}` must be a nonnegative integer from 0 ' + f'to the number of areas {len(contents)} minus 1.') + raise ServerError.FileSyntaxError(info) + + if not 0 <= int_afk_sendto < len(contents): + info = (f'afk_sendto `{item["afk_sendto"]}` must be a nonnegative integer from 0 ' + f'to the number of areas {len(contents)} minus 1.') + raise ServerError.FileSyntaxError(info) + + try: + int_afk_delay = int(item['afk_delay']) + except (ValueError, TypeError, OverflowError): + info = (f'afk_delay `{item["afk_delay"]}` must be a nonnegative integer from 0 ' + f'to 360.') + raise ServerError.FileSyntaxError(info) + + if not 0 <= int_afk_delay <= 360: + info = (f'afk_delay `{item["afk_delay"]}` must be a nonnegative integer from 0 ' + f'to 360.') + raise ServerError.FileSyntaxError(info) + area_parameters.append(item.copy()) temp_area_names.add(item['area']) current_area_id += 1 @@ -244,7 +270,7 @@ def validate_contents(self, contents, extra_parameters=None): if found_uncheckable_restricted_chars: info = ('WARNING: Some areas provided default restricted characters. However, no ' 'server character list was provided, so no checks whether restricted ' - 'characters were in the character list of the server were performed.') + 'characters were in the default character list of the server were performed.') print(info) return area_parameters diff --git a/server/validate/backgrounds.py b/server/validate/backgrounds.py index 8a23a2429..216c4a24d 100644 --- a/server/validate/backgrounds.py +++ b/server/validate/backgrounds.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 diff --git a/server/validate/characters.py b/server/validate/characters.py index 1416e05c1..88339cfa5 100644 --- a/server/validate/characters.py +++ b/server/validate/characters.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 diff --git a/server/validate/config.py b/server/validate/config.py index 3d0b03bfc..9b37aff34 100644 --- a/server/validate/config.py +++ b/server/validate/config.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -49,8 +50,6 @@ def validate_contents(self, contents, extra_parameters=None) -> Dict[str, Any]: 'announce_areas', 'motd', 'hostname', - - 'rp_mode_enabled', ] for field in mandatory_fields: @@ -131,7 +130,6 @@ def validate_contents(self, contents, extra_parameters=None) -> Dict[str, Any]: 'def_numfaces': int, 'def_modifier': str, - 'rp_mode_enabled': bool, 'blackout_background': str, 'default_area_description': str, 'party_lights_timeout': (float, int), diff --git a/server/validate/gimp.py b/server/validate/gimp.py index f27dc911f..ea1d3addf 100644 --- a/server/validate/gimp.py +++ b/server/validate/gimp.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 diff --git a/server/validate/music.py b/server/validate/music.py index cfa260da0..511d0ed33 100644 --- a/server/validate/music.py +++ b/server/validate/music.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -72,12 +73,12 @@ def validate_contents(self, contents, extra_parameters=None) -> List[Dict[str, A for (i, item) in enumerate(contents.copy()): category = str(item['category']) # Prevent conflicts with AO Protocol - if Constants.includes_omniwhy_exploit(category): + if Constants.is_aoprotocol_injection_vulnerable(category): info = (f'Category {category} contains characters that could cause issues with ' f'certain AO clients, so it is invalid. Please rename the category and try ' f'again.') raise ServerError.FileSyntaxError(info) - + songs = item['songs'] for (j, song) in enumerate(songs): if song is None: @@ -118,17 +119,17 @@ def validate_contents(self, contents, extra_parameters=None) -> List[Dict[str, A f'song {j}: {name} in category {i}: {category} was not a string or ' f'number.') raise ServerError.FileSyntaxError(msg) - + name = str(name) length = float(length) source = str(source) # Prevent conflicts with AO Protocol - if Constants.includes_omniwhy_exploit(name): + if Constants.is_aoprotocol_injection_vulnerable(name): info = (f'Song name {name} contains characters that could cause issues with ' f'certain AO clients, so it is invalid. Please rename the song and try ' f'again.') raise ServerError.FileSyntaxError(info) - + # Prevent names that may be interpreted as a directory with . or .. # This prevents sending the client an entry to their music list which may be read as # including a relative directory diff --git a/server/validate_assets.py b/server/validate_assets.py index 8372aaded..78e9b37bc 100644 --- a/server/validate_assets.py +++ b/server/validate_assets.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -136,7 +137,7 @@ def read_sysargv_and_validate(self, default=''): except OSError as exc: # Should only land here for paths that cannot be resolved - print(f'Invalid file name {file_name}.') + print(f'Invalid file name {file_name}: {exc}.') else: if not file_name.upper().endswith('.YAML'): msg = f'Invalid file name {file_name} (file extension must be .yaml)' diff --git a/server/zone_manager.py b/server/zone_manager.py index 6005b1fa8..bab19026a 100644 --- a/server/zone_manager.py +++ b/server/zone_manager.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -24,22 +25,25 @@ """ from __future__ import annotations + import typing from typing import Any, Dict, Set + +from server.constants import Constants +from server.exceptions import ClientError, ZoneError +from server.subscriber import Listener + if typing.TYPE_CHECKING: # Avoid circular referencing from server.area_manager import AreaManager from server.client_manager import ClientManager + from server.hub_manager import _Hub from server.tsuserver import TsuserverDR -from server.constants import Constants -from server.exceptions import ClientError, ZoneError -from server.subscriber import Listener - class ZoneManager: """ A mutable data type for a manager for the zones in a server. - Contains the Zone object definition, as well as the server's zone list. + Contains the Zone object definition, as well as the hub's zone list. """ class Zone: @@ -48,15 +52,25 @@ class Zone: that occur in areas in the zone. """ - def __init__(self, server: TsuserverDR, zone_id: str, areas: Set[AreaManager.Area], - watchers: Set[ClientManager.Client]): + def __init__( + self, + server: TsuserverDR, + manager: ZoneManager, + hub: _Hub, + zone_id: str, + areas: Set[AreaManager.Area], + watchers: Set[ClientManager.Client]): """ Initialization method for a zone. Parameters ---------- - server: TsuserverDR - Server the zone belongs to + server : TsuserverDR + The server this zone belongs to. + manager : ZoneManager + The manager of this zone. + hub: _Hub + The hub this zone belongs to. zone_id: str Identifier of zone. areas: set of AreaManager.Area @@ -65,7 +79,10 @@ def __init__(self, server: TsuserverDR, zone_id: str, areas: Set[AreaManager.Are Set of clients who are watching the zone. """ - self._server = server + self.server = server + self.manager = manager + self.hub = hub + self._zone_id = zone_id self._areas = set() self._watchers = set() @@ -193,7 +210,7 @@ def _remove_area(self, area: AreaManager.Area): # If no more areas, delete the zone if not self._areas: - self._server.zone_manager.delete_zone(self._zone_id) + self.manager.delete_zone(self._zone_id) def _cleanup_removed_area(self, area: AreaManager.Area): self.listener.unsubscribe(area) @@ -320,13 +337,13 @@ def _remove_watcher(self, user: ClientManager.Client): # If no more watchers nor players, delete the zone if not self._watchers and not self._players: - self._server.zone_manager.delete_zone(self._zone_id) + self.manager.delete_zone(self._zone_id) user.send_ooc('(X) Zone `{}` that you were in was automatically ended as no one ' 'was in an area part of it or was watching it anymore.' - .format(self._zone_id), is_staff=True) + .format(self._zone_id)) user.send_ooc_others('Zone `{}` was automatically ended as no one was in an ' 'area part of it or was watching it anymore.' - .format(self._zone_id), is_officer=True) + .format(self._zone_id), is_officer=True, in_hub=None) def _cleanup_removed_watcher(self, user: ClientManager.Client): user.zone_watched = None @@ -377,6 +394,12 @@ def _add_player(self, user: ClientManager.Client): user.autopass = True user.send_ooc('Your autopass was automatically turned on.') + if self.is_property('Autoglance'): + autoglance = self.get_property('Autoglance') + if autoglance and not user.autoglance: + user.autoglance = True + user.send_ooc('Your autoglance was automatically turned on.') + def get_players(self) -> Set[ClientManager.Client]: """ Return the set of players in an area part of the current zone. @@ -434,13 +457,13 @@ def _remove_player(self, user: ClientManager.Client): # If no more watchers nor players, delete the zone if not self._watchers and not self._players: - self._server.zone_manager.delete_zone(self._zone_id) + self.manager.delete_zone(self._zone_id) user.send_ooc('(X) Zone `{}` that you were in was automatically ended as no one ' 'was in an area part of it or was watching it anymore.' - .format(self._zone_id), is_staff=True) + .format(self._zone_id)) user.send_ooc_others('Zone `{}` was automatically ended as no one was in an ' 'area part of it or was watching it anymore.' - .format(self._zone_id), is_officer=True) + .format(self._zone_id), is_officer=True, in_hub=None) def _cleanup_removed_player(self, player: ClientManager.Client): self.listener.unsubscribe(player) @@ -620,7 +643,7 @@ def delete(self): return self._is_deleted = True - self._server.zone_manager.delete_zone(self._zone_id) + self.manager.delete_zone(self._zone_id) for area in self._areas: self._cleanup_removed_area(area) @@ -642,7 +665,7 @@ def is_deleted(self) -> bool: return self._is_deleted def _on_area_client_left_final(self, area, client=None, old_displayname=None, - ignore_bleeding=False, ignore_autopass=False): + ignore_bleeding=False, ignore_autopass=False): """ Default callback for zone signaling a client left. This is executed after all other actions related to moving the player to a new area have been executed: in particular, @@ -689,7 +712,8 @@ def _on_area_client_entered_final(self, area, client=None, old_area=None, client : ClientManager.Client, optional The client that has entered. The default is None. old_area : AreaManager.Area - The old area the client has come from. The default is None. + The old area the client has come from (possibly None for a newly connected user). + The default is None. old_displayname : str, optional The old displayed name of the client before they changed area. This will typically change only if the client's character or showname are taken. The default is None. @@ -737,7 +761,7 @@ def _on_client_destroyed(self, player): self._check_structure() def _check_structure(self): - self._server.zone_manager._check_structure() + self.manager._check_structure() def __repr__(self) -> str: """ @@ -751,17 +775,21 @@ def __repr__(self) -> str: return 'Z::{}:{}:{}'.format(self._zone_id, self._areas, self._watchers) - def __init__(self, server: TsuserverDR): + def __init__(self, server: TsuserverDR, hub: _Hub): """ Create a zone manager object. Parameters ---------- - server: TsuserverDR + server : TsuserverDR The server this zone manager belongs to. + hub : _Hub + The hub this zone manager belongs to. """ - self._server = server + self.server = server + self.hub = hub + self._zones = dict() self._zone_limit = 10000 @@ -808,7 +836,7 @@ def new_zone(self, areas: Set[AreaManager.Area], watchers: Set[ClientManager.Cli .format(Constants.cjoin(conflict_watchers))) raise ZoneError.WatcherConflictError(message) - zone = self.Zone(self._server, zone_id, areas, watchers) + zone = self.Zone(self.server, self, self.hub, zone_id, areas, watchers) self._zones[zone_id] = zone self._check_structure() return zone_id @@ -907,13 +935,13 @@ def get_zone_id(self, zone_tag) -> str: def get_info(self) -> str: """ - List all zones in the server, as well as some of their properties. + List all zones in the hub, as well as some of their properties. If there are no zones, return a special message instead. Returns ------- str: - All zones in the server. + All zones in the hub. """ if not self._zones: @@ -1002,53 +1030,61 @@ def _check_structure(self): # 1. assert len(self._zones.keys()) < self._zone_limit, ( 'Expected the server cap of {} to be enforced, found the server linked to ' - '{} zones instead.'.format(self._zone_limit, len(self._zones.keys()))) + '{} zones instead.'.format(self._zone_limit, len(self._zones.keys())) + ) # 2. for zone_id, zone in self._zones.items(): assert zone._zone_id == zone_id, ( 'Expected zone {} associated with ID {} to have the same ID, found it had ID ' - '{} instead.'.format(zone, zone_id, zone._zone_id)) + '{} instead.'.format(zone, zone_id, zone._zone_id) + ) for zone in self._zones.values(): # 3. conflicting_areas = [area for area in zone._areas if area in areas_so_far] assert not conflicting_areas, ( 'Expected no conflicting areas, but zone {} introduces repeated areas {}.' - .format(zone, conflicting_areas)) + .format(zone, conflicting_areas) + ) # 4. for area in zone._areas: assert area.in_zone == zone, ( 'Expected area {} to recognize it being a part of zone {}, found it ' - 'recognized {} instead.'.format(area, zone, area.in_zone)) + 'recognized {} instead.'.format(area, zone, area.in_zone) + ) areas_so_far.add(area) # 5. conflicting_watchers = [watch for watch in zone._watchers if watch in watchers_so_far] assert not conflicting_watchers, ( 'Expected no conflicting watchers, but zone {} introduces conflicting watchers ' - '{}.'.format(zone, conflicting_watchers)) + '{}.'.format(zone, conflicting_watchers) + ) # 6. for watcher in zone._watchers: assert watcher.zone_watched == zone, ( 'Expected watcher {} to recognize it is watching zone {}, found it ' - 'recognized {} instead.'.format(watcher, zone, watcher.zone_watched)) + 'recognized {} instead.'.format(watcher, zone, watcher.zone_watched) + ) watchers_so_far.add(watcher) # 7. - for area in self._server.area_manager.get_areas(): + for area in self.hub.area_manager.get_areas(): if area in areas_so_far: continue assert area.in_zone is None, ( 'Expected area {} not part of a zone to recognize it not being in a zone, ' - 'found it recognized {} instead.'.format(area, area.in_zone)) + 'found it recognized {} instead.'.format(area, area.in_zone) + ) # 8. - for watcher in self._server.get_clients(): + for watcher in self.hub.get_players(): if watcher in watchers_so_far: continue assert watcher.zone_watched is None, ( 'Expected watcher {} to recognize that it is not watching a zone, found it ' - 'recognized it watched {} instead.'.format(watcher, watcher.zone_watched)) + 'recognized it watched {} instead.'.format(watcher, watcher.zone_watched) + ) diff --git a/start_server.py b/start_server.py index 1568d678e..68aa0f254 100644 --- a/start_server.py +++ b/start_server.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -34,10 +35,10 @@ def _mandatory_python_version_check(): current_python_tuple = sys.version_info current_python_simple = 'Python {}.{}.{}'.format(*current_python_tuple[:3]) - if current_python_tuple < (3, 7): + if current_python_tuple < (3, 9): # This deliberately uses .format() because f-strings were not available prior to - # Python 3.7 - msg = ('This version of TsuserverDR requires at least Python 3.7. You currently have ' + # Python 3.7, and 3.7 < 3.9 + msg = ('This version of TsuserverDR requires at least Python 3.9. You currently have ' '{}. Please refer to README.md for instructions on updating.' .format(current_python_simple)) raise RuntimeError(msg) @@ -48,9 +49,9 @@ def _upcoming_python_version_check(): current_python_simple = 'Python {}.{}.{}'.format(*current_python_tuple[:3]) if current_python_tuple < (3, 9): msg = (f'WARNING: The upcoming major release of TsuserverDR (4.4.0) will be requiring ' - f'at least Python 3.9. You currently have {current_python_simple}. ' - f'Please consider upgrading to at least Python 3.9 soon. You may find ' - f'additional instructions on updating in README.md') + f'at least Python 3.9. You currently have {current_python_simple}. ' + f'Please consider upgrading to at least Python 3.9 soon. You may find ' + f'additional instructions on updating in README.md') logger.log_print(msg) @@ -80,6 +81,8 @@ async def _abnormal_shutdown(exception, server=None): try: await server.normal_shutdown() except Exception as exception2: + server.shutting_down = True + logger.log_print('Unable to gracefully shut down: Forcing a shutdown.') etype, evalue, etraceback = (type(exception2), exception2, exception2.__traceback__) info = "\r\n" + "".join(traceback.format_exception(etype, evalue, etraceback)) diff --git a/test.py b/test.py index f2520fc92..a2ca51b2d 100644 --- a/test.py +++ b/test.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 diff --git a/tests/structures.py b/tests/structures.py index 97ce6734b..bdaa3bce2 100644 --- a/tests/structures.py +++ b/tests/structures.py @@ -1,7 +1,8 @@ -# TsuserverDR, a Danganronpa Online server based on tsuserver3, an Attorney Online server +# TsuserverDR, server software for Danganronpa Online based on tsuserver3, +# which is server software for Attorney Online. # # Copyright (C) 2016 argoneus (original tsuserver3) -# Current project leader: 2018-22 Chrezm/Iuvee +# (C) 2018-22 Chrezm/Iuvee (further additions) # # 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 @@ -16,22 +17,32 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from __future__ import annotations + import asyncio +import pkgutil import random +import typing import unittest -from typing import List, Set +from typing import List, Set, Tuple, Type, Union + +import server -from unittest.mock import Mock +from server import logger from server.network.ao_protocol import AOProtocol from server.area_manager import AreaManager from server.client_manager import ClientManager from server.constants import Constants from server.exceptions import TsuserverException -from server.tasker import Tasker +from server.task_manager import TaskManager from server.tsuserver import TsuserverDR +if typing.TYPE_CHECKING: + from asyncio.proactor_events import _ProactorSocketTransport + + from server.hub_manager import _Hub class _Unittest(unittest.TestCase): @classmethod @@ -40,15 +51,17 @@ def setUpClass(cls): cls.skipTest('', reason='') print('\nTesting {}: '.format(cls.__name__), end=' ') cls.server = _TestTsuserverDR() - cls.clients: List[_TestClientManager._Testclient] = cls.server.client_list - cls.area0: AreaManager.Area = cls.server.area_manager.get_area_by_id(0) - cls.area1: AreaManager.Area = cls.server.area_manager.get_area_by_id(1) - cls.area2: AreaManager.Area = cls.server.area_manager.get_area_by_id(2) - cls.area3: AreaManager.Area = cls.server.area_manager.get_area_by_id(3) - cls.area4: AreaManager.Area = cls.server.area_manager.get_area_by_id(4) - cls.area5: AreaManager.Area = cls.server.area_manager.get_area_by_id(5) - cls.area6: AreaManager.Area = cls.server.area_manager.get_area_by_id(6) - cls.area7: AreaManager.Area = cls.server.area_manager.get_area_by_id(7) + cls.clients: List[_TestClientManager._TestClient] = cls.server.client_list + + default_hub = cls.server.hub_manager.get_default_managee() + cls.area0: AreaManager.Area = default_hub.area_manager.get_area_by_id(0) + cls.area1: AreaManager.Area = default_hub.area_manager.get_area_by_id(1) + cls.area2: AreaManager.Area = default_hub.area_manager.get_area_by_id(2) + cls.area3: AreaManager.Area = default_hub.area_manager.get_area_by_id(3) + cls.area4: AreaManager.Area = default_hub.area_manager.get_area_by_id(4) + cls.area5: AreaManager.Area = default_hub.area_manager.get_area_by_id(5) + cls.area6: AreaManager.Area = default_hub.area_manager.get_area_by_id(6) + cls.area7: AreaManager.Area = default_hub.area_manager.get_area_by_id(7) cls.a0_name: str = cls.area0.name cls.a1_name: str = cls.area1.name @@ -62,7 +75,7 @@ def setUpClass(cls): @classmethod def setUpClients(cls, num_clients): - cls.server.make_clients(num_clients) + cls.server.make_test_clients(num_clients) err_characters = 'Invalid characters.yaml for the purposes of testing (must be original).' @@ -122,7 +135,7 @@ def assert_property(self, yes, no, group, pred): if group == 'C': structure = self.server.client_manager.clients elif group == 'A': - structure = self.server.area_manager.get_areas() + structure = self.server.hub_manager.get_default_managee().area_manager.get_areas() if yes == 1: yes = {x for x in structure if x not in no} @@ -139,10 +152,16 @@ def tearDown(self): Check if any packets were unaccounted for. Only do so if test passed. """ - # Test checker by hynekcer (2016): https://stackoverflow.com/a/39606065 + # Test checker by hynekcer (2022): https://stackoverflow.com/a/39606065 + + if hasattr(self._outcome, 'errors'): + # Python 3.4 - 3.10 (These two methods have no side effects) + result = self.defaultTestResult() # these 2 methods have no side effects + self._feedErrorsToResult(result, self._outcome.errors) + else: + # Python 3.11+ + result = self._outcome.result - result = self.defaultTestResult() # these 2 methods have no side effects - self._feedErrorsToResult(result, self._outcome.errors) error = self.list2reason(result.errors) failure = self.list2reason(result.failures) @@ -157,10 +176,10 @@ def tearDown(self): @classmethod def tearDownClass(cls): - for (logger, handler) in cls.server.logger_handlers: + for (_logger, handler) in cls.server.logger_handlers: handler.close() - logger.removeHandler(handler) - cls.server.disconnect_all() + _logger.removeHandler(handler) + cls.server.disconnect_all_test_clients() class _TestSituation3(_Unittest): @@ -226,16 +245,35 @@ def setUpClass(cls): class _TestClientManager(ClientManager): class _TestClient(ClientManager.Client): - def __init__(self, *args, protocol=None): + def __init__( + self, + server: _TestTsuserverDR, + hub: _Hub, + transport: None, + user_id: int, + ipid: int, + protocol: AOProtocol = None + ): """ Overwrites client_manager.ClientManager.Client.__init__ """ - super().__init__(*args) + super().__init__( + server=server, + hub=hub, + transport=transport, + user_id=user_id, + ipid=ipid, + protocol=protocol, + ) - self.protocol = protocol self.received_packets = list() self.received_ooc = list() self.received_ic = list() + self.server: _TestTsuserverDR # Only to indicate type + + def get_ipreal(self) -> str: + return "127.0.0.1" + def disconnect(self, assert_no_outstanding=False): """ Overwrites client_manager.ClientManager.Client.disconnect """ @@ -354,7 +392,7 @@ def make_normie(self, over=True, other_over=lambda c: True): def move_area(self, area_id, discard_packets=True, discard_trivial=False): as_command = random.randint(0, 1) - area = self.server.area_manager.get_area_by_id(area_id) + area = self.hub.area_manager.get_area_by_id(area_id) if as_command: self.ooc('/area {}'.format(area_id)) else: @@ -381,13 +419,11 @@ def move_area(self, area_id, discard_packets=True, discard_trivial=False): for packet in packets_to_discard: self.discard_packet(packet, somewhere=True) - """ - # Discard IC blankpost and OOC standard notification - _, x = self.search_match(['MS', None], - self.received_packets, somewhere=True, remove_match=True, - allow_partial_match=True) - self.discard_ic(x[1]) - """ + # # Discard IC blankpost and OOC standard notification + # _, x = self.search_match(['MS', None], + # self.received_packets, somewhere=True, remove_match=True, + # allow_partial_match=True) + # self.discard_ic(x[1]) host = self.convert_word_to_symbol(self.server.config['hostname']) _, x = self.search_match(['CT', (host, 'Changed area to')], @@ -867,35 +903,69 @@ def receive_command_stc(self, command_type, *args): if buffer: self.send_command_cts(buffer) - def get_ipreal(self) -> str: - return "127.0.0.1" - - def __init__(self, server): + def __init__(self, server: _TestTsuserverDR): """ Overwrites client_manager.ClientManager.__init__ """ - super().__init__(server, client_obj=self._TestClient) + super().__init__(server, default_client_type=_TestClientManager._TestClient) + self.clients: Set[_TestClientManager._TestClient] # For typing + + def new_client( + self, + client_type: Type[_TestClientManager._TestClient] = None, + hub: _Hub = None, + transport: _ProactorSocketTransport = None, + protocol: AOProtocol = None, + ) -> Tuple[_TestClient, bool]: + """ Overwrites client_manager.ClientManager.new_client """ + + return super().new_client( + client_type=client_type, + hub=hub, + transport=transport, + protocol=protocol + ) class _TestTsuserverDR(TsuserverDR): def __init__(self): """ Overwrites tsuserver.TsuserverDR.__init__ """ self.loop = asyncio.get_event_loop() - - super().__init__(client_manager=_TestClientManager, in_test=True) - self.ao_protocol = AOProtocol - self.client_list = [None] * self.config['playerlimit'] - - self.tasker = Tasker(self) - - def create_client(self): - new_ao_protocol = self.ao_protocol(self) + logger.log_print = (lambda *args, **kwargs: None) + logger.log_server = (lambda *args, **kwargs: None) + + super().__init__(client_manager_type=_TestClientManager) + + self.client_list: List[ + Union[_TestClientManager._TestClient, None] + ] = [None] * self.config['playerlimit'] + self.task_manager = TaskManager(self) + self.client_manager: _TestClientManager # For typing + + def new_client( + self, + transport: _ProactorSocketTransport, + protocol: AOProtocol = None, + ) -> Tuple[_TestClientManager._TestClient, bool]: + """ Overwrites new_client only to override return type """ + + return super().new_client(transport, protocol) + + def send_error_report(self, client: ClientManager.Client, cmd: str, args: List[str], + ex: Exception): + """ Overwrite tsuserver.TsuserverDR.send_error_report """ + super().send_error_report(client, cmd, args, ex) + raise ex + + def make_test_client(self, char_id: int = -1, hdid: str = 'FAKEHDID', + attempts_to_fully_join: bool = True) -> _TestClientManager._TestClient: + new_ao_protocol = AOProtocol(self) new_ao_protocol.connection_made(None) - return new_ao_protocol.client - - def make_client(self, char_id, hdid='FAKEHDID'): - c = self.create_client() + c: _TestClientManager._TestClient = new_ao_protocol.client + if not attempts_to_fully_join: + return c if c.disconnected: return c + c.send_command_cts("askchaa#%") c.send_command_cts("RC#%") c.send_command_cts("RM#%") @@ -903,7 +973,7 @@ def make_client(self, char_id, hdid='FAKEHDID'): c.send_command_cts("CC#{}#{}#{}#%".format(c.id, char_id, hdid)) if char_id >= 0: - exp = self.character_manager.get_characters()[char_id] + exp = self.hub_manager.get_default_managee().character_manager.get_characters()[char_id] else: exp = self.config['spectator_name'] res = c.get_char_name() @@ -912,8 +982,8 @@ def make_client(self, char_id, hdid='FAKEHDID'): return c - def make_clients(self, number, hdid_list=None, - user_list=None) -> Set[_TestClientManager._TestClient]: + def make_test_clients(self, number: int, hdid_list: List[str] = None, + user_list: List[str] = None) -> Set[_TestClientManager._TestClient]: if hdid_list is None: hdid_list = ['FAKEHDID'] * number else: @@ -924,16 +994,17 @@ def make_clients(self, number, hdid_list=None, else: assert len(user_list) == number + default_hub = self.hub_manager.get_default_managee() for i in range(number): - area = self.area_manager.default_area() - for j in range(len(self.character_manager.get_characters())): + area = default_hub.area_manager.default_area() + for j in range(len(default_hub.character_manager.get_characters())): if area.is_char_available(j): char_id = j break else: char_id = -1 - client = self.make_client(char_id, hdid=hdid_list[i]) + client = self.make_test_client(char_id, hdid=hdid_list[i]) client.name = user_list[i] for j, existing_client in enumerate(self.client_list): @@ -944,14 +1015,14 @@ def make_clients(self, number, hdid_list=None, j = -1 assert j == client.id, (j, client.id) - def disconnect_client(self, client_id, assert_no_outstanding=False): + def disconnect_test_client(self, client_id: int, assert_no_outstanding: bool = False): client = self.client_list[client_id] if not client: raise KeyError(client_id) client.disconnect(assert_no_outstanding=assert_no_outstanding) - def disconnect_all(self, assert_no_outstanding=False): + def disconnect_all_test_clients(self, assert_no_outstanding: bool = False): for (i, client) in enumerate(self.client_list): if client: client.disconnect() @@ -959,3 +1030,16 @@ def disconnect_all(self, assert_no_outstanding=False): client.assert_no_packets() client.assert_no_ooc() self.client_list[i] = None + + def get_clients(self) -> List[_TestClientManager._TestClient]: + return super().get_clients() + + def override_random(self, random_factory: Type): + # In today's edition of "You can do what in Python?" + # We will override a standard library import of files elsewhere in the project structure + # Where "files elsewhere" means all of the files within the `server` folder + module_infos = pkgutil.iter_modules(['server']) + for module_info in module_infos: + name = module_info.name + module = getattr(server, name) + module.random = random_factory diff --git a/tests/test_client_connection.py b/tests/test_aaa_client_connection.py similarity index 88% rename from tests/test_client_connection.py rename to tests/test_aaa_client_connection.py index c29d09a65..7c18b4169 100644 --- a/tests/test_client_connection.py +++ b/tests/test_aaa_client_connection.py @@ -6,13 +6,13 @@ _standard_client_version = ('1', '2', '2') -class TestClientConnection(_Unittest): +class TestAAA_ClientConnection(_Unittest): def test_01_client0_connect(self): """ Situation: Client selects the server on the lobby screen. """ - self.clients[0] = self.server.create_client() + self.clients[0] = self.server.make_test_client(attempts_to_fully_join=False) c = self.clients[0] c.assert_packet('decryptor', 34) c.assert_packet('ID', (0, None, None)) @@ -26,7 +26,7 @@ def test_02_client1_connect(self): Situation: Another client selects the server on the lobby screen. """ - self.clients[1] = self.server.create_client() + self.clients[1] = self.server.make_test_client(attempts_to_fully_join=False) c = self.clients[1] c.assert_packet('decryptor', 34) c.assert_packet('ID', (1, None, None)) @@ -71,7 +71,7 @@ def test_04_client0_joinserver(self): """ # Starts off as normal - self.clients[0] = self.server.create_client() + self.clients[0] = self.server.make_test_client(attempts_to_fully_join=False) c = self.clients[0] c.assert_packet('decryptor', 34) c.assert_packet('ID', (0, None, None)) @@ -82,7 +82,7 @@ def test_04_client0_joinserver(self): # But then it carries on c.send_command_cts("askchaa#%") - c.assert_packet('SI', (len(self.server.character_manager.get_characters()), None, None), over=True) + c.assert_packet('SI', (len(c.hub.character_manager.get_characters()), None, None), over=True) c.send_command_cts("RC#%") c.assert_packet('SC', None, over=True) c.send_command_cts("RM#%") @@ -121,7 +121,7 @@ def test_05_client1_joinandpickchar(self): """ # Starts off as normal - self.clients[1] = self.server.create_client() + self.clients[1] = self.server.make_test_client(attempts_to_fully_join=False) c = self.clients[1] c.assert_packet('decryptor', 34) c.assert_packet('ID', (1, None, None)) @@ -132,7 +132,7 @@ def test_05_client1_joinandpickchar(self): # Join server c.send_command_cts("askchaa#%") - c.assert_packet('SI', (len(self.server.character_manager.get_characters()), None, None), over=True) + c.assert_packet('SI', (len(c.hub.character_manager.get_characters()), None, None), over=True) c.send_command_cts("RC#%") c.assert_packet('SC', None, over=True) c.send_command_cts("RM#%") @@ -163,7 +163,7 @@ def test_05_client1_joinandpickchar(self): c.send_command_cts("CC#1#0#FAKEHDID#%") # Pick char 0 c.assert_packet('PV', (1, 'CID', 0)) # 1 because second client online c.assert_packet('GM', '', over=True) - assert(c.get_char_name() == self.server.character_manager.get_characters()[0]) + assert(c.get_char_name() == c.hub.character_manager.get_characters()[0]) # Check number of clients num_clients = len(self.server.client_manager.clients) @@ -178,7 +178,7 @@ def test_06_client2_joinandpicksamechar(self): """ # Starts off as normal - self.clients[2] = self.server.create_client() + self.clients[2] = self.server.make_test_client(attempts_to_fully_join=False) c = self.clients[2] c.assert_packet('decryptor', 34) c.assert_packet('ID', (2, None, None)) @@ -189,7 +189,8 @@ def test_06_client2_joinandpicksamechar(self): # Join server c.send_command_cts("askchaa#%") - c.assert_packet('SI', (len(self.server.character_manager.get_characters()), None, None), over=True) + c.assert_packet('SI', (len(c.hub.character_manager.get_characters()), None, None), + over=True) c.send_command_cts("RC#%") c.assert_packet('SC', None, over=True) c.send_command_cts("RM#%") @@ -222,7 +223,7 @@ def test_06_client2_joinandpicksamechar(self): c.send_command_cts("CC#2#1#FAKEHDID#%") # Attempt to pick char 1 c.assert_packet('PV', (2, 'CID', 1)) # 2 because third client online c.assert_packet('GM', '', over=True) - assert(c.get_char_name() == self.server.character_manager.get_characters()[1]) + assert(c.get_char_name() == c.hub.character_manager.get_characters()[1]) # Check number of clients num_clients = len(self.server.client_manager.clients) @@ -246,7 +247,7 @@ def test_07_client0_pickchar(self): c.send_command_cts("CC#0#3#FAKEHDID#%") # Attempt to pick char 3 c.assert_packet('PV', (0, 'CID', 3)) # 0 because first client online c.assert_packet('GM', '', over=True) - assert(c.get_char_name() == self.server.character_manager.get_characters()[3]) + assert(c.get_char_name() == c.hub.character_manager.get_characters()[3]) self.assertEqual(len(self.server.client_manager.clients), 3) self.assertEqual(self.server.get_player_count(), 3) @@ -256,7 +257,7 @@ def test_08_automatedclientcreation(self): Situation: Player joins and picks char 2 (automated). """ - self.clients[3] = self.server.make_client(2) + self.clients[3] = self.server.make_test_client(2) self.assertEqual(len(self.server.client_manager.clients), 4) self.assertEqual(self.server.get_player_count(), 4) @@ -273,7 +274,7 @@ def test_09_middleguydisconnect(self): self.assertEqual(len(self.server.client_manager.clients), 3) self.assertEqual(self.server.get_player_count(), 3) - self.clients[1] = self.server.make_client(0) + self.clients[1] = self.server.make_test_client(0) self.assertEqual(self.clients[1].id, 1) self.assertEqual(len(self.server.client_manager.clients), 4) self.assertEqual(self.server.get_player_count(), 4) @@ -301,13 +302,13 @@ def test_10_twooffourreconnect(self): self.assertEqual(self.server.get_player_count(), 2) # Now c0 picks char_id=1 - self.clients[0] = self.server.make_client(1) + self.clients[0] = self.server.make_test_client(1) self.assertEqual(self.clients[0].id, 0) self.assertEqual(len(self.server.client_manager.clients), 3) self.assertEqual(self.server.get_player_count(), 3) # And c2 picks char_id=3 - self.clients[2] = self.server.make_client(3) + self.clients[2] = self.server.make_test_client(3) self.assertEqual(self.clients[2].id, 2) self.assertEqual(len(self.server.client_manager.clients), 4) self.assertEqual(self.server.get_player_count(), 4) @@ -317,11 +318,11 @@ def test_11_allreconnect(self): Situation: All clients disconnect and reconnect (automated). """ - self.server.disconnect_all() + self.server.disconnect_all_test_clients() self.assertEqual(len(self.server.client_manager.clients), 0) self.assertEqual(self.server.get_player_count(), 0) - self.server.make_clients(4) + self.server.make_test_clients(4) self.assertEqual(len(self.server.client_manager.clients), 4) self.assertEqual(self.server.get_player_count(), 4) @@ -331,7 +332,7 @@ def test_12_defaultareafull(self): are forced to choose Spectator. """ - self.server.make_clients(1) + self.server.make_test_clients(1) self.assertEqual(len(self.server.client_manager.clients), 5) self.assertEqual(self.server.get_player_count(), 5) @@ -340,19 +341,19 @@ def test_13_serverfull(self): Situation: Server is filled up, then more clients try to join. """ - self.server.make_clients(95) # Nothing should happen + self.server.make_test_clients(95) # Nothing should happen self.assertEqual(len(self.server.client_manager.clients), 100) self.assertEqual(self.server.get_player_count(), 100) - self.server.make_clients(1) # Would overlap + self.server.make_test_clients(1) # Would overlap self.assertEqual(len(self.server.client_manager.clients), 100) self.assertEqual(self.server.get_player_count(), 100) - self.server.make_clients(1) # Tries again, should still be denied + self.server.make_test_clients(1) # Tries again, should still be denied self.assertEqual(len(self.server.client_manager.clients), 100) self.assertEqual(self.server.get_player_count(), 100) - self.server.disconnect_all() + self.server.disconnect_all_test_clients() self.assertEqual(len(self.server.client_manager.clients), 0) self.assertEqual(self.server.get_player_count(), 0) @@ -360,6 +361,6 @@ def test_14_toomanyathesametime(self): """ Situation: Tester of test function, filling up beyond server capacity. """ - self.server.make_clients(105) + self.server.make_test_clients(105) self.assertEqual(len(self.server.client_manager.clients), 100) self.assertEqual(self.server.get_player_count(), 100) diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 225572b11..54b1fdad7 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -8,7 +8,6 @@ class _TestAuthorization(_TestSituation3): @classmethod def setUpClass(cls): super().setUpClass() - cls.guardpass = cls.server.config['guardpass'] cls.modpass = cls.server.config['modpass'] cls.cmpass = cls.server.config['cmpass'] cls.gmpass = cls.server.config['gmpass'] @@ -28,12 +27,10 @@ def setUpClass(cls): for x in range(1, 7) if x != current_day} cls.all_passwords = {cls.wrong, - cls.wrong, cls.gmpass, cls.daily_gmpass, cls.cmpass, - cls.modpass, - cls.guardpass}.union(cls.not_daily_gmpasses) + cls.modpass}.union(cls.not_daily_gmpasses) cls.wrong_passwords = cls.all_passwords.copy() # Set by child tests def test_01_NoClientLoggedinYet(self): @@ -44,7 +41,6 @@ def test_01_NoClientLoggedinYet(self): self.assertFalse(c.is_mod) self.assertFalse(c.is_cm) self.assertFalse(c.is_gm) - # self.assertTrue(c.in_rp) # Assumes server starts with RP mode on class _TestAuthorizationSingleRank(_TestAuthorization): @@ -64,7 +60,6 @@ def test_02_WrongLogin(self): self.assertFalse(self.good_rank(c)) self.assertFalse(self.bad_rank1(c)) self.assertFalse(self.bad_rank2(c)) - # self.assertTrue(c.in_rp) # Assumes server starts with RP mode on def test_03_RightLoginAndRelogin(self): """ @@ -573,8 +568,8 @@ def test_07_DisconnectionRemovesRank(self): """ for i in range(2): - self.server.disconnect_client(i) - self.server.make_clients(1) + self.server.disconnect_test_client(i) + self.server.make_test_clients(1) c = self.clients[i] self.assertFalse(c.is_mod) self.assertFalse(c.is_cm) diff --git a/tests/test_blind.py b/tests/test_blind.py index dbdfc847e..a871a59fc 100644 --- a/tests/test_blind.py +++ b/tests/test_blind.py @@ -155,7 +155,7 @@ def setUpClass(cls): cls.c2.ooc('/blind 0') cls.c0.move_area(4) cls.c1.move_area(4) - cls.server.make_clients(1) + cls.server.make_test_clients(1) cls.c4 = cls.clients[4] cls.c4.ooc('/switch {}'.format(cls.server.config['spectator_name'])) cls.c4.move_area(4) diff --git a/tests/test_blood.py b/tests/test_blood.py index 99625277a..9815b75a6 100644 --- a/tests/test_blood.py +++ b/tests/test_blood.py @@ -172,7 +172,7 @@ def test_01_seebleedingjustonarrival(self): self.c2.move_area(0, discard_trivial=True) self.c2.assert_ooc('You see {} is bleeding.'.format(self.c0_dname)) self.c2.assert_ooc('You spot some blood in the area.', ooc_over=True) - self.c2.assert_ic('(Something catches your attention)', over=True) + self.c2.assert_ic('[Something catches your attention]', over=True) self.c2.move_area(6, discard_trivial=True) self.c2.assert_no_ooc() @@ -192,7 +192,7 @@ def test_02_notifybleedingondepartureandarrival(self): self.c1.assert_no_ooc() self.c2.assert_ooc('You see {} arrive to the area while bleeding.'.format(self.c0_dname), ooc_over=True) - self.c2.assert_ic('(Something catches your attention)', over=True) + self.c2.assert_ic('[Something catches your attention]', over=True) self.c3.assert_no_ooc() def test_03_bloodtrail(self): @@ -206,13 +206,13 @@ def test_03_bloodtrail(self): self.c1.move_area(5, discard_trivial=True) self.c1.assert_ooc('You spot a blood trail leading to the {} and the {}.' .format(self.a0_name, self.a6_name), ooc_over=True) - self.c1.assert_ic('(Something catches your attention)', over=True) + self.c1.assert_ic('[Something catches your attention]', over=True) self.c1.move_area(6, discard_trivial=True) self.c1.assert_ooc('You see {} is bleeding.'.format(self.c0_dname)) self.c1.assert_ooc('You spot a blood trail leading to the {}.'.format(self.a5_name), ooc_over=True) - self.c1.assert_ic('(Something catches your attention)', over=True) + self.c1.assert_ic('[Something catches your attention]', over=True) def test_04_bleedonbloodtrail(self): """ diff --git a/tests/test_bloodnotify_existsmear.py b/tests/test_bloodnotify_existsmear.py index 35765a7cf..b056df679 100644 --- a/tests/test_bloodnotify_existsmear.py +++ b/tests/test_bloodnotify_existsmear.py @@ -63,24 +63,24 @@ def test_01_normalarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You see {} is bleeding.'.format(self.c3_dname)) self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You see {} and {} are bleeding.' .format(*sorted([self.c0_dname, self.c2_dname]))) self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(1, discard_trivial=True) self.c4.assert_no_ooc() self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) def test_02_blindarrival(self): """ @@ -96,21 +96,21 @@ def test_02_blindarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(1, discard_trivial=True) self.c4.assert_no_ooc() self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) def test_03_blinddeafarrival(self): """ @@ -126,22 +126,22 @@ def test_03_blinddeafarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(1, discard_trivial=True) self.c4.assert_no_ooc() self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) def test_04_deafarrival(self): """ @@ -157,20 +157,20 @@ def test_04_deafarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You see {} is bleeding.'.format(self.c3_dname)) self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You see {} and {} are bleeding.' .format(*sorted([self.c0_dname, self.c2_dname]))) self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(1, discard_trivial=True) self.c4.assert_no_ooc() self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) class TestBloodNotifyExistSmear_02_NoLights(_TestBloodTrailSmeared): @classmethod @@ -204,17 +204,17 @@ def test_01_normalarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(1, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.', over=True) @@ -222,7 +222,7 @@ def test_01_normalarrival(self): self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) def test_02_blindarrival(self): """ @@ -238,22 +238,22 @@ def test_02_blindarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(1, discard_trivial=True) self.c4.assert_no_ooc() self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) def test_03_blinddeafarrival(self): """ @@ -269,22 +269,22 @@ def test_03_blinddeafarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(1, discard_trivial=True) self.c4.assert_no_ooc() self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) def test_04_deafarrival(self): """ @@ -300,17 +300,17 @@ def test_04_deafarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(1, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.', over=True) @@ -318,7 +318,7 @@ def test_04_deafarrival(self): self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) class TestBloodNotifyExistSmear_03_SwitchLights(TestBloodNotifyExistSmear_02_NoLights): def test_01_normalarrival(self): @@ -333,18 +333,18 @@ def test_01_normalarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights on') self.c3.assert_packet('BN', self.area6.background) self.c3.assert_ooc('You turned the lights on.') self.c3.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c3.assert_ic('(Something catches your attention)', over=True) + self.c3.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.area6.background) self.c4.assert_ooc('The lights were turned on.') self.c4.assert_ooc('You see {} is bleeding.'.format(self.c3_dname)) self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights off') self.c3.assert_packet('BN', self.blackout_background) @@ -357,26 +357,26 @@ def test_01_normalarrival(self): self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights on') self.c0.assert_packet('BN', self.area7.background) self.c0.assert_ooc('The lights were turned on.') self.c0.assert_ooc('You see {} is bleeding.'.format(self.c2_dname)) self.c0.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c0.assert_ic('(Something catches your attention)', over=True) + self.c0.assert_ic('[Something catches your attention]', over=True) self.c2.assert_packet('BN', self.area7.background) self.c2.assert_ooc('You turned the lights on.') self.c2.assert_ooc('You see {} is bleeding.'.format(self.c0_dname)) self.c2.assert_ooc('(X) You spot a smeared blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) # Staff get hidden blood trail - self.c2.assert_ic('(Something catches your attention)', over=True) + self.c2.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.area7.background) self.c4.assert_ooc('The lights were turned on.') self.c4.assert_ooc('You see {} and {} are bleeding.' .format(*sorted([self.c0_dname, self.c2_dname]))) self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights off') self.c0.assert_packet('BN', self.blackout_background) @@ -395,13 +395,13 @@ def test_01_normalarrival(self): self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.area4.background) self.c4.assert_ooc('You turned the lights on.') self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights off') self.c4.assert_packet('BN', self.blackout_background) @@ -425,13 +425,13 @@ def test_01_normalarrival(self): self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.area2.background) self.c4.assert_ooc('You turned the lights on.') self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights off') self.c4.assert_packet('BN', self.blackout_background) @@ -452,13 +452,13 @@ def test_02_blindarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights on') self.c3.assert_packet('BN', self.area6.background) self.c3.assert_ooc('You turned the lights on.') self.c3.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c3.assert_ic('(Something catches your attention)', over=True) + self.c3.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.blackout_background) self.c4.assert_ooc('You hear a flicker.', over=True) self.c4.assert_no_ic() # Blind person gets no IC attention @@ -473,20 +473,20 @@ def test_02_blindarrival(self): ### self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights on') self.c0.assert_packet('BN', self.area7.background) self.c0.assert_ooc('The lights were turned on.') self.c0.assert_ooc('You see {} is bleeding.'.format(self.c2_dname)) self.c0.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c0.assert_ic('(Something catches your attention)', over=True) + self.c0.assert_ic('[Something catches your attention]', over=True) self.c2.assert_packet('BN', self.area7.background) self.c2.assert_ooc('You turned the lights on.') self.c2.assert_ooc('You see {} is bleeding.'.format(self.c0_dname)) self.c2.assert_ooc('(X) You spot a smeared blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) # STAFF! - self.c2.assert_ic('(Something catches your attention)', over=True) + self.c2.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.blackout_background) self.c4.assert_ooc('You hear a flicker.', over=True) self.c4.assert_no_ic() # Blind person gets no IC attention @@ -507,7 +507,7 @@ def test_02_blindarrival(self): ### self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.blackout_background) @@ -535,7 +535,7 @@ def test_02_blindarrival(self): ### self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.blackout_background) @@ -561,13 +561,13 @@ def test_03_blinddeafarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights on') self.c3.assert_packet('BN', self.area6.background) self.c3.assert_ooc('You turned the lights on.') self.c3.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c3.assert_ic('(Something catches your attention)', over=True) + self.c3.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.blackout_background, over=True) self.c3.ooc('/lights off') @@ -579,20 +579,20 @@ def test_03_blinddeafarrival(self): ### self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights on') self.c0.assert_packet('BN', self.area7.background) self.c0.assert_ooc('The lights were turned on.') self.c0.assert_ooc('You see {} is bleeding.'.format(self.c2_dname)) self.c0.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c0.assert_ic('(Something catches your attention)', over=True) + self.c0.assert_ic('[Something catches your attention]', over=True) self.c2.assert_packet('BN', self.area7.background) self.c2.assert_ooc('You turned the lights on.') self.c2.assert_ooc('You see {} is bleeding.'.format(self.c0_dname)) self.c2.assert_ooc('(X) You spot a smeared blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c2.assert_ic('(Something catches your attention)', over=True) + self.c2.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.blackout_background, over=True) self.c2.ooc('/lights off') @@ -610,7 +610,7 @@ def test_03_blinddeafarrival(self): ### self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.blackout_background) @@ -638,7 +638,7 @@ def test_03_blinddeafarrival(self): ### self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.blackout_background) @@ -664,18 +664,18 @@ def test_04_deafarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights on') self.c3.assert_packet('BN', self.area6.background) self.c3.assert_ooc('You turned the lights on.') self.c3.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c3.assert_ic('(Something catches your attention)', over=True) + self.c3.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.area6.background) self.c4.assert_ooc('The lights were turned on.') self.c4.assert_ooc('You see {} is bleeding.'.format(self.c3_dname)) self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights off') self.c3.assert_packet('BN', self.blackout_background) @@ -688,26 +688,26 @@ def test_04_deafarrival(self): self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights on') self.c0.assert_packet('BN', self.area7.background) self.c0.assert_ooc('The lights were turned on.') self.c0.assert_ooc('You see {} is bleeding.'.format(self.c2_dname)) self.c0.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c0.assert_ic('(Something catches your attention)', over=True) + self.c0.assert_ic('[Something catches your attention]', over=True) self.c2.assert_packet('BN', self.area7.background) self.c2.assert_ooc('You turned the lights on.') self.c2.assert_ooc('You see {} is bleeding.'.format(self.c0_dname)) self.c2.assert_ooc('(X) You spot a smeared blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c2.assert_ic('(Something catches your attention)', over=True) + self.c2.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.area7.background) self.c4.assert_ooc('The lights were turned on.') self.c4.assert_ooc('You see {} and {} are bleeding.' .format(*sorted([self.c0_dname, self.c2_dname]))) self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights off') self.c0.assert_packet('BN', self.blackout_background) @@ -726,13 +726,13 @@ def test_04_deafarrival(self): self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.area4.background) self.c4.assert_ooc('You turned the lights on.') self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights off') self.c4.assert_packet('BN', self.blackout_background) @@ -756,13 +756,13 @@ def test_04_deafarrival(self): self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.area2.background) self.c4.assert_ooc('You turned the lights on.') self.c4.assert_ooc('You spot some smeared blood in the area.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights off') self.c4.assert_packet('BN', self.blackout_background) diff --git a/tests/test_bloodnotify_existtrail.py b/tests/test_bloodnotify_existtrail.py index 232ac494d..2f7c4aff2 100644 --- a/tests/test_bloodnotify_existtrail.py +++ b/tests/test_bloodnotify_existtrail.py @@ -57,19 +57,19 @@ def test_01_normalarrival(self): self.c4.assert_ooc('You see {} is bleeding.'.format(self.c3_dname)) self.c4.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a4_name, self.a7_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You see {} and {} are bleeding.' .format(*sorted([self.c0_dname, self.c2_dname]))) self.c4.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a5_name, self.a6_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(2, discard_trivial=True) self.c4.assert_no_ooc() @@ -88,15 +88,15 @@ def test_02_blindarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(2, discard_trivial=True) self.c4.assert_no_ooc() @@ -115,15 +115,15 @@ def test_03_blinddeafarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(2, discard_trivial=True) self.c4.assert_no_ooc() @@ -143,19 +143,19 @@ def test_04_deafarrival(self): self.c4.assert_ooc('You see {} is bleeding.'.format(self.c3_dname)) self.c4.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a4_name, self.a7_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You see {} and {} are bleeding.' .format(*sorted([self.c0_dname, self.c2_dname]))) self.c4.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a5_name, self.a6_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(2, discard_trivial=True) self.c4.assert_no_ooc() @@ -190,17 +190,17 @@ def test_01_normalarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.', over=True) @@ -219,15 +219,15 @@ def test_02_blindarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(2, discard_trivial=True) self.c4.assert_no_ooc() @@ -246,15 +246,15 @@ def test_03_blinddeafarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(2, discard_trivial=True) self.c4.assert_no_ooc() @@ -273,17 +273,17 @@ def test_04_deafarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.move_area(2, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.', over=True) @@ -301,20 +301,20 @@ def test_01_normalarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights on') self.c3.assert_packet('BN', self.area6.background) self.c3.assert_ooc('You turned the lights on.') self.c3.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a4_name, self.a7_name), ooc_over=True) - self.c3.assert_ic('(Something catches your attention)', over=True) + self.c3.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.area6.background) self.c4.assert_ooc('The lights were turned on.') self.c4.assert_ooc('You see {} is bleeding.'.format(self.c3_dname)) self.c4.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a4_name, self.a7_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights off') self.c3.assert_packet('BN', self.blackout_background) @@ -327,7 +327,7 @@ def test_01_normalarrival(self): self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights on') self.c0.assert_packet('BN', self.area7.background) @@ -335,20 +335,20 @@ def test_01_normalarrival(self): self.c0.assert_ooc('You see {} is bleeding.'.format(self.c2_dname)) self.c0.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c0.assert_ic('(Something catches your attention)', over=True) + self.c0.assert_ic('[Something catches your attention]', over=True) self.c2.assert_packet('BN', self.area7.background) self.c2.assert_ooc('You turned the lights on.') self.c2.assert_ooc('You see {} is bleeding.'.format(self.c0_dname)) self.c2.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c2.assert_ic('(Something catches your attention)', over=True) + self.c2.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.area7.background) self.c4.assert_ooc('The lights were turned on.') self.c4.assert_ooc('You see {} and {} are bleeding.' .format(*sorted([self.c0_dname, self.c2_dname]))) self.c4.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights off') self.c0.assert_packet('BN', self.blackout_background) @@ -367,14 +367,14 @@ def test_01_normalarrival(self): self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.area4.background) self.c4.assert_ooc('You turned the lights on.') self.c4.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a5_name, self.a6_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights off') self.c4.assert_packet('BN', self.blackout_background) @@ -407,14 +407,14 @@ def test_02_blindarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights on') self.c3.assert_packet('BN', self.area6.background) self.c3.assert_ooc('You turned the lights on.') self.c3.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a4_name, self.a7_name), ooc_over=True) - self.c3.assert_ic('(Something catches your attention)', over=True) + self.c3.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.blackout_background) self.c4.assert_ooc('You hear a flicker.', over=True) @@ -428,7 +428,7 @@ def test_02_blindarrival(self): ### self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You hear faint drops of blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights on') self.c0.assert_packet('BN', self.area7.background) @@ -436,13 +436,13 @@ def test_02_blindarrival(self): self.c0.assert_ooc('You see {} is bleeding.'.format(self.c2_dname)) self.c0.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c0.assert_ic('(Something catches your attention)', over=True) + self.c0.assert_ic('[Something catches your attention]', over=True) self.c2.assert_packet('BN', self.area7.background) self.c2.assert_ooc('You turned the lights on.') self.c2.assert_ooc('You see {} is bleeding.'.format(self.c0_dname)) self.c2.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c2.assert_ic('(Something catches your attention)', over=True) + self.c2.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.blackout_background) self.c4.assert_ooc('You hear a flicker.', over=True) self.c4.assert_no_ic() # Blind gets no IC @@ -463,7 +463,7 @@ def test_02_blindarrival(self): ### self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.blackout_background) @@ -500,14 +500,14 @@ def test_03_blinddeafarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights on') self.c3.assert_packet('BN', self.area6.background) self.c3.assert_ooc('You turned the lights on.') self.c3.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a4_name, self.a7_name), ooc_over=True) - self.c3.assert_ic('(Something catches your attention)', over=True) + self.c3.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.blackout_background, over=True) self.c3.ooc('/lights off') @@ -519,7 +519,7 @@ def test_03_blinddeafarrival(self): ### self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights on') self.c0.assert_packet('BN', self.area7.background) @@ -527,13 +527,13 @@ def test_03_blinddeafarrival(self): self.c0.assert_ooc('You see {} is bleeding.'.format(self.c2_dname)) self.c0.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c0.assert_ic('(Something catches your attention)', over=True) + self.c0.assert_ic('[Something catches your attention]', over=True) self.c2.assert_packet('BN', self.area7.background) self.c2.assert_ooc('You turned the lights on.') self.c2.assert_ooc('You see {} is bleeding.'.format(self.c0_dname)) self.c2.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c2.assert_ic('(Something catches your attention)', over=True) + self.c2.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.blackout_background, over=True) self.c2.ooc('/lights off') @@ -551,7 +551,7 @@ def test_03_blinddeafarrival(self): ### self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.blackout_background) @@ -588,20 +588,20 @@ def test_04_deafarrival(self): self.c4.move_area(6, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights on') self.c3.assert_packet('BN', self.area6.background) self.c3.assert_ooc('You turned the lights on.') self.c3.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a4_name, self.a7_name), ooc_over=True) - self.c3.assert_ic('(Something catches your attention)', over=True) + self.c3.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.area6.background) self.c4.assert_ooc('The lights were turned on.') self.c4.assert_ooc('You see {} is bleeding.'.format(self.c3_dname)) self.c4.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a4_name, self.a7_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c3.ooc('/lights off') self.c3.assert_packet('BN', self.blackout_background) @@ -614,7 +614,7 @@ def test_04_deafarrival(self): self.c4.move_area(7, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights on') self.c0.assert_packet('BN', self.area7.background) @@ -622,20 +622,20 @@ def test_04_deafarrival(self): self.c0.assert_ooc('You see {} is bleeding.'.format(self.c2_dname)) self.c0.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c0.assert_ic('(Something catches your attention)', over=True) + self.c0.assert_ic('[Something catches your attention]', over=True) self.c2.assert_packet('BN', self.area7.background) self.c2.assert_ooc('You turned the lights on.') self.c2.assert_ooc('You see {} is bleeding.'.format(self.c0_dname)) self.c2.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c2.assert_ic('(Something catches your attention)', over=True) + self.c2.assert_ic('[Something catches your attention]', over=True) self.c4.assert_packet('BN', self.area7.background) self.c4.assert_ooc('The lights were turned on.') self.c4.assert_ooc('You see {} and {} are bleeding.' .format(*sorted([self.c0_dname, self.c2_dname]))) self.c4.assert_ooc('You spot a blood trail leading to the {}.' .format(self.a6_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c2.ooc('/lights off') self.c0.assert_packet('BN', self.blackout_background) @@ -654,14 +654,14 @@ def test_04_deafarrival(self): self.c4.move_area(4, discard_trivial=True) self.c4.assert_ooc('You enter a pitch dark room.') self.c4.assert_ooc('You smell blood.', ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights on') self.c4.assert_packet('BN', self.area4.background) self.c4.assert_ooc('You turned the lights on.') self.c4.assert_ooc('You spot a blood trail leading to the {}, the {} and the {}.' .format(self.a0_name, self.a5_name, self.a6_name), ooc_over=True) - self.c4.assert_ic('(Something catches your attention)', over=True) + self.c4.assert_ic('[Something catches your attention]', over=True) self.c4.ooc('/lights off') self.c4.assert_packet('BN', self.blackout_background) diff --git a/tests/test_deafen.py b/tests/test_deafen.py index d54bb550a..42be834a7 100644 --- a/tests/test_deafen.py +++ b/tests/test_deafen.py @@ -9,7 +9,7 @@ def setUpClass(cls): def convo1(self): self.c2.sic('Oi m8.') - self.c0.assert_ic('(Your ears are ringing)', folder=self.c2_cname, anim='happy', over=True) + self.c0.assert_ic('[Your ears are ringing]', folder=self.c2_cname, anim='happy', over=True) self.c2.assert_ic('Oi m8.', folder=self.c2_cname, anim='happy', over=True) others = [self.c1, self.c3] @@ -23,7 +23,7 @@ def convo1(self): c.assert_no_ic() self.c0.sic('Cant hear you.', anim='sad') - self.c0.assert_ic('(Your ears are ringing)', folder=self.c0_cname, anim='sad', over=True) + self.c0.assert_ic('[Your ears are ringing]', folder=self.c0_cname, anim='sad', over=True) self.c2.assert_ic('Cant hear you.', folder=self.c0_cname, anim='sad', over=True) for c in others: @@ -71,19 +71,19 @@ def test_02_deafhearsnothing(self): """ self.c0.sic('Hello?') - self.c0.assert_ic('(Your ears are ringing)', folder=self.c0_cname, anim='happy', over=True) + self.c0.assert_ic('[Your ears are ringing]', folder=self.c0_cname, anim='happy', over=True) self.c1.assert_ic('Hello?', folder=self.c0_cname, anim='happy', over=True) self.c2.assert_no_ic() self.c3.assert_no_ic() self.c1.sic('Yes I can hear you.') - self.c0.assert_ic('(Your ears are ringing)', folder=self.c1_cname, anim='happy', over=True) + self.c0.assert_ic('[Your ears are ringing]', folder=self.c1_cname, anim='happy', over=True) self.c1.assert_ic('Yes I can hear you.', folder=self.c1_cname, anim='happy', over=True) self.c2.assert_no_ic() self.c3.assert_no_ic() self.c0.sic('I cant hear you :(') - self.c0.assert_ic('(Your ears are ringing)', folder=self.c0_cname, anim='happy', over=True) + self.c0.assert_ic('[Your ears are ringing]', folder=self.c0_cname, anim='happy', over=True) self.c1.assert_ic('I cant hear you :(', folder=self.c0_cname, anim='happy', over=True) self.c2.assert_no_ic() self.c3.assert_no_ic() @@ -102,13 +102,13 @@ def test_03_deafchangesarea(self): self.c0.move_area(4) self.c2.sic('Oi m8.') - self.c0.assert_ic('(Your ears are ringing)', folder=self.c2_cname, anim='happy', over=True) + self.c0.assert_ic('[Your ears are ringing]', folder=self.c2_cname, anim='happy', over=True) self.c2.assert_ic('Oi m8.', folder=self.c2_cname, anim='happy', over=True) self.c1.assert_no_ic() self.c3.assert_no_ic() self.c0.sic('Cant hear you.', anim='sad') - self.c0.assert_ic('(Your ears are ringing)', folder=self.c0_cname, anim='sad', over=True) + self.c0.assert_ic('[Your ears are ringing]', folder=self.c0_cname, anim='sad', over=True) self.c2.assert_ic('Cant hear you.', folder=self.c0_cname, anim='sad', over=True) self.c1.assert_no_ic() self.c3.assert_no_ic() @@ -138,7 +138,7 @@ def setUpClass(cls): cls.c2.ooc('/deafen 0') cls.c0.move_area(4) cls.c1.move_area(4) - cls.server.make_clients(1) + cls.server.make_test_clients(1) cls.c4 = cls.clients[4] cls.c4.ooc('/switch {}'.format(cls.server.config['spectator_name'])) cls.c4.move_area(4) @@ -252,7 +252,7 @@ def test_01_screamringsears(self): self.c3.ooc('/scream Hi') self.c0.assert_no_ooc() - self.c0.assert_ic('(Your ears are ringing)', over=True) + self.c0.assert_ic('[Your ears are ringing]', over=True) self.c1.assert_ooc('(X) {} [{}] screamed `Hi` ({}).' .format(self.c3_dname, 3, 5), ooc_over=True) self.c1.assert_ic('Hi', over=True) @@ -273,7 +273,7 @@ def test_02_globalicringssears(self): self.c2.assert_ooc('Set up a global IC prefix with /globalic_pre', over=True) self.c2.sic('Hi') - self.c0.assert_ic('(Your ears are ringing)', folder=self.c2_cname, over=True) + self.c0.assert_ic('[Your ears are ringing]', folder=self.c2_cname, over=True) self.c1.assert_ic('Hi', folder=self.c2_cname, over=True) self.c2.assert_ooc('Sent global IC message "Hi" to area {}.' .format(self.a0_name), over=True) @@ -284,7 +284,7 @@ def test_02_globalicringssears(self): self.c2.assert_ooc('Set up a global IC prefix with /globalic_pre', over=True) self.c2.sic('Hi.') - self.c0.assert_ic('(Your ears are ringing)', folder=self.c2_cname, over=True) # client wk + self.c0.assert_ic('[Your ears are ringing]', folder=self.c2_cname, over=True) # client wk self.c1.assert_ic('Hi.', folder=self.c2_cname, over=True) self.c2.assert_ooc('Sent global IC message "Hi." to areas {} through {}.' .format(self.a0_name, self.a1_name), over=True) diff --git a/tests/test_info.py b/tests/test_info.py index 1f3fb3854..60bacd40b 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -102,7 +102,7 @@ def test_02_getonline(self): self.c1.assert_ooc('Online: {}/{}'.format(3, self.server.config['playerlimit']), over=True) self.c3.assert_no_ooc() - self.server.make_clients(4) + self.server.make_test_clients(4) self.c3.ooc('/online') self.c3.assert_ooc('Online: {}/{}'.format(7, self.server.config['playerlimit']), over=True) @@ -135,4 +135,4 @@ def test_02_ping(self): if n == i: self.clients[i].assert_ooc('Pong.', over=True) else: - self.clients[i].assert_no_ooc() \ No newline at end of file + self.clients[i].assert_no_ooc() diff --git a/tests/test_roll.py b/tests/test_roll.py index e2b2c527f..662ef9eb4 100644 --- a/tests/test_roll.py +++ b/tests/test_roll.py @@ -1,3 +1,6 @@ +import random +from typing import List + from .structures import _TestSituation5Mc1Gc2 class _TestRoll(_TestSituation5Mc1Gc2): @@ -25,10 +28,13 @@ class _TestRoll_FixedRNG(_TestRoll): @classmethod def setUpClass(cls): super().setUpClass() - cls.expected_rolls = None + cls.expected_rolls: List[int] = list() + + class fixed_random(): + def __init__(self, expected_rolls: List[int] = None): + if expected_rolls is None: + expected_rolls = list() - class x(): - def __init__(self, expected_rolls=None): cls.expected_rolls = expected_rolls @staticmethod @@ -37,46 +43,14 @@ def randint(a, b): raise KeyError('No expected rolls left for custom random object.') return cls.expected_rolls.pop(0) - cls.randomer = x - cls.server.random = x - cls.do_roll = None # It is expected this is replaced by public_roll or private_roll in - # test creation - - def do_roll(self, arg, expected_rolls, expected_result): - self.roll_type(arg, expected_rolls, expected_result) - - def public_roll(self, arg, expected_rolls, expected_result): - if isinstance(arg, int): - arg = str(arg) - - dice_data = arg.split(' ')[0] - expected_num_faces = int(dice_data.split('d')[-1]) if arg else self.def_numfaces - expected_message = 'rolled {} out of {}'.format(expected_result, expected_num_faces) - - self.server.random = self.randomer(expected_rolls=expected_rolls) - self.c0.ooc('/roll {}'.format(arg)) - self.c0.assert_ooc('You {}.'.format(expected_message), over=True) - self.c1.assert_ooc('{} {}.'.format(self.c0_dname, expected_message), over=True) - self.c2.assert_no_ooc() - self.c3.assert_no_ooc() - self.c4.assert_ooc('{} {}.'.format(self.c0_dname, expected_message), over=True) + @staticmethod + def choices(*args, **kwargs): + return random.choices(*args, **kwargs) - def private_roll(self, arg, expected_rolls, expected_result): - if isinstance(arg, int): - arg = str(arg) + cls.random_factory = fixed_random - dice_data = arg.split(' ')[0] - expected_num_faces = int(dice_data.split('d')[-1]) if arg else self.def_numfaces - expected_message = ('privately rolled {} out of {}' - .format(expected_result, expected_num_faces)) - - self.server.random = self.randomer(expected_rolls=expected_rolls) - self.c0.ooc('/rollp {}'.format(arg)) - self.c0.assert_ooc('You {}.'.format(expected_message), over=True) - self.c1.assert_ooc('(X) {} [{}] {}.'.format(self.c0_dname, 0, expected_message), over=True) - self.c2.assert_no_ooc() - self.c3.assert_no_ooc() - self.c4.assert_ooc('Someone rolled.', over=True) + def do_roll(self, arg, expected_rolls, expected_result): + raise NotImplementedError def test01_nomodifiers(self): """ @@ -97,6 +71,11 @@ def test02_modifiers(self): self.do_roll('20 -2', [4], '4:4-2=2') self.do_roll('20 -2', [2], '2:2-2=|1') + def tearDown(self): + super().tearDown() + self.server.override_random(random) + + class TestRoll_01_WrongArguments(_TestRoll): """ Since there are so many ways rolling can go wrong, an entire test class is dedicated to it. @@ -192,18 +171,40 @@ class TestRoll_02_PublicRoll(_TestRoll_FixedRNG): Wrapper tester for /roll. """ - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.do_roll = cls.public_roll + def do_roll(self, arg, expected_rolls, expected_result): + if isinstance(arg, int): + arg = str(arg) + + dice_data = arg.split(' ')[0] + expected_num_faces = int(dice_data.split('d')[-1]) if arg else self.def_numfaces + expected_message = 'rolled {} out of {}'.format(expected_result, expected_num_faces) + + self.server.override_random(self.random_factory(expected_rolls=expected_rolls)) + self.c0.ooc('/roll {}'.format(arg)) + self.c0.assert_ooc('You {}.'.format(expected_message), over=True) + self.c1.assert_ooc('{} {}.'.format(self.c0_dname, expected_message), over=True) + self.c2.assert_no_ooc() + self.c3.assert_no_ooc() + self.c4.assert_ooc('{} {}.'.format(self.c0_dname, expected_message), over=True) class TestRoll_03_PrivateRoll(_TestRoll_FixedRNG): """ Wrapper tester for /rollp. """ - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.do_roll = cls.private_roll + def do_roll(self, arg, expected_rolls, expected_result): + if isinstance(arg, int): + arg = str(arg) + dice_data = arg.split(' ')[0] + expected_num_faces = int(dice_data.split('d')[-1]) if arg else self.def_numfaces + expected_message = ('privately rolled {} out of {}' + .format(expected_result, expected_num_faces)) + + self.server.override_random(self.random_factory(expected_rolls=expected_rolls)) + self.c0.ooc('/rollp {}'.format(arg)) + self.c0.assert_ooc('You {}.'.format(expected_message), over=True) + self.c1.assert_ooc('(X) {} [{}] {}.'.format(self.c0_dname, 0, expected_message), over=True) + self.c2.assert_no_ooc() + self.c3.assert_no_ooc() + self.c4.assert_ooc('Someone rolled.', over=True) diff --git a/tests/test_senseblock.py b/tests/test_senseblock.py index 6a7a209de..ab51242a3 100644 --- a/tests/test_senseblock.py +++ b/tests/test_senseblock.py @@ -309,12 +309,12 @@ def test_11_doesntpersistonreconnect(self): if not hasattr(self, 'server'): return - self.server.disconnect_client(3) + self.server.disconnect_test_client(3) assert not self.sense_attribute(self.c0) assert not self.sense_attribute(self.c1) assert not self.sense_attribute(self.c2) - self.server.make_clients(1) + self.server.make_test_clients(1) self.c1.assert_no_packets() self.c3 = self.server.client_list[3] diff --git a/tests/test_whisper.py b/tests/test_whisper.py index 3817cda47..627c5e375 100644 --- a/tests/test_whisper.py +++ b/tests/test_whisper.py @@ -436,12 +436,12 @@ def test_01_whisperbothnonstaff(self): recipient.id, sender.area.id)) sender.ooc('/whisper {} {}'.format(identifier, message)) sender.assert_ooc(sent_ooc, ooc_over=True) - sender.assert_ic('(Your ears are ringing)', folder=sender.char_folder, + sender.assert_ic('[Your ears are ringing]', folder=sender.char_folder, pos=sender.pos, char_id=sender.char_id, showname='[W] ' + sender.showname, allow_partial_match=True, over=True) recipient.assert_ooc(recipient_ooc, ooc_over=True) - recipient.assert_ic('(Your ears are ringing)', folder=sender.char_folder, + recipient.assert_ic('[Your ears are ringing]', folder=sender.char_folder, pos=sender.pos, char_id=sender.char_id, showname='[W] ' + sender.showname, allow_partial_match=True, over=True) @@ -471,7 +471,7 @@ def test_02_whisperstaffandnonstaff(self): .format(sender.displayname, recipient.displayname)) sender.ooc('/whisper {} {}'.format(identifier, message)) sender.assert_ooc(sent_ooc, ooc_over=True) - sender.assert_ic('(Your ears are ringing)', folder=sender.char_folder, pos=sender.pos, + sender.assert_ic('[Your ears are ringing]', folder=sender.char_folder, pos=sender.pos, char_id=sender.char_id, showname='[W] ' + sender.showname, allow_partial_match=True, over=True) recipient.assert_ooc(recipient_ooc, ooc_over=True) @@ -513,7 +513,7 @@ def test_03_whisperfromsneakedtononsneaked(self): sender.assert_ic(message, folder='', pos='jud', showname='[W] ' + sender.showname, over=True) recipient.assert_ooc(recipient_ooc, ooc_over=True) - recipient.assert_ic('(Your ears are ringing)', folder='', pos='jud', + recipient.assert_ic('[Your ears are ringing]', folder='', pos='jud', allow_partial_match=True, showname='???', over=True) self.c0.assert_no_packets() self.c1.assert_ooc(staff_ooc, over=True) @@ -540,7 +540,7 @@ def test_04_selfwhisper(self): ooc = 'You whispered `{}` to yourself.'.format(message) client.ooc('/whisper {} {}'.format(identifier, message)) client.assert_ooc(ooc, ooc_over=True) - client.assert_ic('(Your ears are ringing)', folder=client.char_folder, + client.assert_ic('[Your ears are ringing]', folder=client.char_folder, pos=client.pos, showname='[W] ' + client.showname, char_id=client.char_id, allow_partial_match=True, over=True) @@ -593,12 +593,12 @@ def test_05_whispersneakedtosneakedparty(self): recipient.id, sender.area.id)) sender.ooc('/whisper {} {}'.format(identifier, message)) sender.assert_ooc(sent_ooc, ooc_over=True) - sender.assert_ic('(Your ears are ringing)', folder=sender.char_folder, + sender.assert_ic('[Your ears are ringing]', folder=sender.char_folder, pos=sender.pos, char_id=sender.char_id, showname='[W] ' + sender.showname, allow_partial_match=True, over=True) recipient.assert_ooc(recipient_ooc, ooc_over=True) - recipient.assert_ic('(Your ears are ringing)', folder=sender.char_folder, + recipient.assert_ic('[Your ears are ringing]', folder=sender.char_folder, pos=sender.pos, char_id=sender.char_id, showname='[W] ' + sender.showname, allow_partial_match=True, over=True) diff --git a/tests/test_zonebasic.py b/tests/test_zonebasic.py index fbbefec3d..458cf6417 100644 --- a/tests/test_zonebasic.py +++ b/tests/test_zonebasic.py @@ -10,7 +10,7 @@ def setUpClass(cls): cls.c3.move_area(5) cls.c4.move_area(6) cls.c5.move_area(7) - cls.zm = cls.server.zone_manager + cls.zm = cls.server.hub_manager.get_default_managee().zone_manager # Prevent multiclienting warning in tests cls.c0.hdid = '0' @@ -29,7 +29,7 @@ def setUpClass(cls): def assert_zones(self, expected_zones): """ - Assert that the set of zone IDs matches exactly to the server's zone manager's zones + Assert that the set of zone IDs matches exactly to the hub's zone manager's zones """ self.assertEqual(len(expected_zones), len(self.zm.get_zones())) diff --git a/tests/test_zoneextranotifications.py b/tests/test_zoneextranotifications.py index a85659ade..5dddcdd67 100644 --- a/tests/test_zoneextranotifications.py +++ b/tests/test_zoneextranotifications.py @@ -1,3 +1,6 @@ +import random +from typing import List + from .test_zonebasic import _TestZone class TestZoneExtraNotifications_01_EnterLeave(_TestZone): @@ -248,11 +251,11 @@ def test_02_staffshownamechange(self): # Removing custom showname altogether self.c1.ooc('/showname_set {}'.format(3)) self.c0.assert_no_packets() - self.c1.assert_ooc('You have removed the showname of client {}.'.format(3), over=True) - self.c2.assert_ooc('(X) {} [{}] removed the showname `{}` of client {} in your zone ({}).' + self.c1.assert_ooc('You have cleared the showname of client {}.'.format(3), over=True) + self.c2.assert_ooc('(X) {} [{}] cleared the showname `{}` of client {} in your zone ({}).' .format(self.c1_dname, 1, n_showname, 3, self.c3.area.id), over=True) self.c3.assert_packet('SN', '') - self.c3.assert_ooc('Your showname `{}` was removed by a staff member.' + self.c3.assert_ooc('Your showname `{}` was cleared by a staff member.' .format(n_showname), over=True) self.c4.assert_no_packets() self.c5.assert_no_packets() @@ -308,15 +311,19 @@ class TestZoneExtraNotifications_03_ChangeCharacter(_TestZone): @classmethod def setUpClass(cls): super().setUpClass() - cls.sc0_name = cls.server.character_manager.get_characters()[0] - cls.sc1_name = cls.server.character_manager.get_characters()[1] - cls.sc2_name = cls.server.character_manager.get_characters()[2] - cls.sc3_name = cls.server.character_manager.get_characters()[3] + cls.default_hub = cls.server.hub_manager.get_default_managee() + cls.sc0_name = cls.default_hub.character_manager.get_characters()[0] + cls.sc1_name = cls.default_hub.character_manager.get_characters()[1] + cls.sc2_name = cls.default_hub.character_manager.get_characters()[2] + cls.sc3_name = cls.default_hub.character_manager.get_characters()[3] cls.scs_name = cls.server.config['spectator_name'] - cls.expected_next_results = None + cls.expected_next_results: List[int] = list() + + class fixed_random(): + def __init__(self, expected_next_results: List[int] = None): + if expected_next_results is None: + expected_next_results = list() - class x(): - def __init__(self, expected_next_results=None): cls.expected_next_results = expected_next_results @staticmethod @@ -329,7 +336,7 @@ def choice(seq): .format(to_return, seq)) return to_return - cls.random_factory = x + cls.random_factory = fixed_random def test_01_fromcharselect(self): """ @@ -366,16 +373,16 @@ def test_02_fromcharconflict(self): C0: 3 C1: 2 C2: 2 C3: 3 C4: -1 C5: -1 """ - self.server.random = self.random_factory(expected_next_results=[2]) # Force random to be 2 + self.server.override_random(self.random_factory(expected_next_results=[2])) self.c3.move_area(self.c0.area.id, discard_trivial=True) self.c0.assert_no_packets() self.c1.assert_no_packets() self.c2.assert_ooc('(X) Client {} had their character changed from `{}` to `{}` in your ' - 'zone as their old character was taken in their new area ({}).' + 'zone as their old character was unavailable in their new area ({}).' .format(3, self.sc3_name, self.sc2_name, self.c0.area.id), over=True) self.c3.assert_packet('PV', (3, 'CID', 2)) - self.c3.assert_ooc('Your character was taken in your new area, switched to `{}`.' + self.c3.assert_ooc('Your character was unavailable in your new area, switched to `{}`.' .format(self.sc2_name), over=True) self.c4.assert_no_packets() self.c5.assert_no_packets() @@ -388,7 +395,7 @@ def test_03_frompreviouscharrestricted(self): C0: 3 C1: 2 C2: 2 C3: 3 C4: -1 C5: -1 """ - self.server.random = self.random_factory(expected_next_results=[0]) # Force random to be 0 + self.server.override_random(self.random_factory(expected_next_results=[0])) self.area6.restricted_chars = {self.sc2_name} self.c3.move_area(6, discard_trivial=True) @@ -411,7 +418,7 @@ def test_04_fromnewcharrestricted(self): C0: 3 C1: 1 C2: 2 C3: 3 C4: -1 C5: -1 """ - self.server.random = self.random_factory(expected_next_results=[3]) + self.server.override_random(self.random_factory(expected_next_results=[3])) self.c1.move_area(6) self.c1.ooc('/char_restrict {}'.format(self.sc0_name)) @@ -443,7 +450,7 @@ def test_05_fromstafflogoutusingrestricted(self): C0: 3 C1: 1 C2: 2 C3: 3 C4: -1 C5: -1 """ - self.server.random = self.random_factory(expected_next_results=[1]) + self.server.override_random(self.random_factory(expected_next_results=[1])) self.c1.send_command_cts("CC#1#0#FAKEHDID#%") # Attempt to pick char 0 self.c1.assert_packet('PV', (1, 'CID', 0), over=True) self.c2.discard_all() @@ -470,7 +477,7 @@ def test_06_fromrandomchar(self): """ self.area6.restricted_chars = set() - self.server.random = self.random_factory(expected_next_results=[0]) + self.server.override_random(self.random_factory(expected_next_results=[0])) self.c4.ooc('/randomchar') self.c0.assert_no_packets() @@ -499,9 +506,15 @@ def test_07_fromswitch(self): self.c3.assert_no_packets() self.c4.assert_packet('PV', (4, 'CID', 2)) self.c4.assert_ooc('Changed character to {}.' - .format(self.server.character_manager.get_characters()[2]), over=True) + .format(self.default_hub.character_manager.get_characters()[2]), + over=True) self.c5.assert_no_packets() + def tearDown(self): + super().tearDown() + self.server.override_random(random) + + class TestZoneExtraNotifications_04_Disconnection(_TestZone): def test_01_nonstaffleaves(self): """ diff --git a/tests/test_zzznodebug.py b/tests/test_zzz_nodebug.py similarity index 92% rename from tests/test_zzznodebug.py rename to tests/test_zzz_nodebug.py index 187efbb7c..dcbf6e574 100644 --- a/tests/test_zzznodebug.py +++ b/tests/test_zzz_nodebug.py @@ -1,6 +1,6 @@ from .structures import _TestSituation4Mc12 -class TestZZZNoDebug_01_Basic(_TestSituation4Mc12): +class TestZZZ_NoDebug_01_Basic(_TestSituation4Mc12): def test_01_noprintpackets(self): """ Situation: Make sure server does not print packets