diff --git a/.gitignore b/.gitignore index 0bc19173..37517552 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,15 @@ -*~ -*.json -*.pem -*.log -\.byebug_history *.db -\#* -\.\#* -missing* -scores* -missing*.txt -scores*.txt +*.swp +*.sql +*.so +.byebug_history +/.*/** +Gemfile.lock +/.ruby-lsp +/**/__pycache__ +/logs +/sorbet +/bin +/util/hash_* +/.bundle +/ext/Makefile \ No newline at end of file diff --git a/Gemfile b/Gemfile index 6c4be3fb..6cc8c378 100644 --- a/Gemfile +++ b/Gemfile @@ -1,19 +1,60 @@ source 'https://rubygems.org' do - gem 'ascii_charts' - gem 'discordrb' - gem 'activerecord' - gem 'yaml_db' - gem 'gruff' - gem 'mysql2' - group :debug, :test do - gem 'byebug' + # Gems required to connect to Discord + group :bot do + gem 'discordrb', github: 'shardlab/discordrb', branch: 'main' end - group :test do - gem 'test-unit' - gem 'mocha' + # Gems required to run the servers and connect to the database + group :server do + gem 'activerecord' + gem 'mysql2' + gem 'puma' + gem 'rails' + gem 'yaml_db' + end + + # Gems for parsing or converting different formats + group :data do + gem 'damerau-levenshtein' + gem 'html-to-markdown' + gem 'nokogiri' + gem 'rss' + gem 'rubyzip' + gem 'unicode-emoji' + end + + # Gems for image manipulation + group :imaging do + gem 'chunky_png' + gem 'gifenc', github: 'edelkas/gifenc', branch: 'master' + gem 'gruff' + gem 'matplotlib' + gem 'octicons' + gem 'oily_png', github: 'edelkas/oily_png', branch: 'dev' + gem 'rexml' + gem 'rmagick' + gem 'svg-graph' + end + + # Gems for providing "intellisense" + group :development do +# gem 'sorbet' +# gem 'sorbet-runtime' +# gem 'tapioca' + end + + # Gems for debugging the code + group :development, :debug do + gem 'memory_profiler' + gem 'pry-byebug' + end + + # Gems for testing the code + group :development, :test do gem 'database_cleaner' - gem 'factory_girl' + gem 'factory_bot' + gem 'mocha' + gem 'test-unit' end end diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index ab02c092..00000000 --- a/Gemfile.lock +++ /dev/null @@ -1,77 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - activemodel (5.0.2) - activesupport (= 5.0.2) - activerecord (5.0.2) - activemodel (= 5.0.2) - activesupport (= 5.0.2) - arel (~> 7.0) - activesupport (5.0.2) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (~> 0.7) - minitest (~> 5.1) - tzinfo (~> 1.1) - arel (7.1.4) - ascii_charts (0.9.1) - byebug (9.0.6) - concurrent-ruby (1.0.5) - discordrb (3.2.1) - discordrb-webhooks (~> 3.2.0.1) - opus-ruby - rbnacl (~> 3.4.0) - rest-client - websocket-client-simple (>= 0.3.0) - discordrb-webhooks (3.2.0.1) - rest-client - domain_name (0.5.20170404) - unf (>= 0.0.5, < 1.0.0) - event_emitter (0.2.5) - ffi (1.9.18-x86-mingw32) - http-cookie (1.0.3) - domain_name (~> 0.5) - i18n (0.8.1) - metaclass (0.0.4) - mime-types (3.1) - mime-types-data (~> 3.2015) - mime-types-data (3.2016.0521) - minitest (5.10.1) - mocha (1.2.1) - metaclass (~> 0.0.1) - netrc (0.11.0) - opus-ruby (1.0.1) - ffi - power_assert (1.0.1) - rbnacl (3.4.0) - ffi - rest-client (2.0.1-x86-mingw32) - ffi (~> 1.9) - http-cookie (>= 1.0.2, < 2.0) - mime-types (>= 1.16, < 4.0) - netrc (~> 0.8) - test-unit (3.2.3) - power_assert - thread_safe (0.3.6) - tzinfo (1.2.3) - thread_safe (~> 0.1) - unf (0.1.4) - unf_ext - unf_ext (0.0.7.3-x86-mingw32) - websocket (1.2.4) - websocket-client-simple (0.3.0) - event_emitter - websocket - -PLATFORMS - x86-mingw32 - -DEPENDENCIES - activerecord! - ascii_charts! - byebug! - discordrb! - mocha! - test-unit! - -BUNDLED WITH - 1.11.2 diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index acc39ba2..00000000 --- a/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2017 Liam Mitchell - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index cde45a94..a387196f 100644 --- a/README.md +++ b/README.md @@ -1,216 +1,230 @@ # inne++ - the N++ discord server chatbot -***inne++*** is a chatbot for the N++ discord server #highscores channel. inne++ serves up a new level of the day in the channel at midnight MST (07:00 GMT) every night, as well as a new episode of the week every week on Friday at the same time. +[![N++ Discord Server](https://img.shields.io/badge/N++-Discord-%235865F2.svg?logo=discord&logoColor=FFFFFF)](https://discord.gg/nplusplus) + +***inne++*** is a bot for the [Discord server](https://discord.gg/nplusplus) of the game [N++](https://www.metanetsoftware.com/games/nplusplus) by [Metanet Software](https://www.metanetsoftware.com/). It provides plenty of functionalities related to highscoring, userlevels, custom mappacks, and more; and even serves as a 3rd party server for N++. + +### Table of contents +- [For users](#for-users) + - [Basics](#basics) + - [Userlevel support](#userlevel-support) + - [Mappack support](#mappack-support) + - [Regular events](#regular-events) + - [More advanced parameters](#more-advanced-parameters) + - [Tips and Tricks](#tips-and-tricks) +- [For developers](#for-developers) + - [Requirements](#requirements) + - [Configuring your environment](#configuring-your-environment) + - [Configuring the database](#configuring-the-database) + - [Configuring the bot](#configuring-the-bot) + - [Building the C native extension](#building-the-c-native-extension) + - [Final remarks](#final-remarks) +- [Credits](#credits) + +## For users + +You can interact with the bot in the [server](https://discord.gg/nplusplus) directly by either pinging or DM'ing it _with natural language_, it understands plenty of commands. To get a complete list and better documentation, use the `help` (or `commands`) command. + +### Basics -inne++ also provides a bunch of commands for players to get high score data about themselves or other players, such as displaying counts of your highscores by rank, finding levels you haven't finished yet, finding levels you can easily improve on, and displaying top-N rankings across all players. In order to do this, inne++ downloads the scores once every half hour. If you'd like inne++ to update the stored scores for a specific level or episode immediately, you can simply request the scores in discord and inne++ will download the latest scores (and display them to you). +Here are **only a few** common examples to get you started. Most of them support many additional options to further refine the query (see the following sections): -## Available commands +Command | Description +------- | ----------- +`help` | Show command list, help and further documentation. +`scores for SI-A-00-00` | Fetches the top20 highscores for the specified level. +`top10 rank` | Computes the global Top10 player rankings. +`userlevel rank` | Computes the global userlevel 0th rankings. +`browse userlevels` | Browse the most recent userlevels. +`screenshot for the basics` | Generates a screenshot for a level _in the default palette_. +`trace SI-A-00-00` | Generate a PNG trace of the 0th run in SI-A-00-00. +`anim SI-A-00-00 0 1` | Generate an animated GIF simulation of multiple runs. +`search for table` | Search (fuzzy) for all levels with "table" in the name. +`stats for xela` | Returns some highscoring statistics and histogram. +`how many for xela` | Query current 0th count for a player. +`points` | Query total point count _for the currently identified player_. +`bottom5 list for xela` | Fetch list of 15th-19th highscores for player xela. +`compare "xela" "jp27ace"` | Compare the highscores of two players. +`top20 table` | Organizes all your top20 counts by tab and type. +`lotd` | Query what the current Level of the Day is. +`analysis 0 1 2 for -++` | Dissect the inputs (L, R, J) of the top 3 runs in -++ (S-C-19-04). +`splits for sia0` | Compute the individual level splits for episode SI-A-00's 0th run. +`sl top10 spread` | List SL levels with largest spread between 0th and 9th scores. +`cleanest su` | Find the cleanest episode runs in SU tab. +`missing top20 si` | Find your missing top20 scores in SI tab. +`worst` | List scores losing more time with respect to the 0th. +`download userlevel 22715` | Download a vanilla map or a userlevel. +`maxed` | List all levels with a fully maxed top20 leaderboard. +`maxable` | List all levels with many ties for 0th. +`random 10 si` | Find 10 random levels in SI tab. +`community` | Compute the current community total level and episode scores. +`twitch` | List all current active N++-related Twitch streams. -All commands can be sent to inne++ either via PM or in a public channel by mentioning @inne++ in the message. inne++ will respond via the same channel. -Commands generally aren't case-sensitive, except for the usernames. +#### Notes: +- All commands are case insensitive. +- If a player is not specified, the identified player (presumably yourself) will be used. +- If tabs are not specified, all of them will be used. +- If the type is not specified, levels and episodes will both be used. -### Display the level of the day/episode of the week -- *what's the lotd* -- *what's the eotw* +### Userlevel support -inne++ will respond with the level of the day or the episode of the week. +There is broad userlevel support, and most highscoring commands (such as the ones above) can be used for userlevels simply by adding `userlevel` somewhere in the command. By default, all userlevels are considered, but this can also be narrowed down to only the latest 500 published userlevels (what the community considers to be "Newest") by also appending `newest`: -### Display a screenshot of a level or episode -- *screenshot of \* +- `userlevel top20 rank` +- `newest userlevel average rank for xela` +- ... -inne++ will respond with a picture of the requested level or episode. +Individual userlevels can be referenced by their ID or by their name. If the name creates ambiguity, the list of matches will be printed instead: -'level' can be a level or episode ID (eg. SI-A-00, SI-A-00-00) or a level name (eg. supercomplexity). +- `userlevel screenshot palette metoro for 71088` +- `userlevel scores for bramble shamble 2: electric boogaloo` -### Display scores for a level or episode -- *scores for \* +Additionally, you can browse or search userlevels, filtering the results by title, author, mode (Solo, Coop, Race) or tab (All, Featured, Hardest, etc), and ordering by many fields: -inne++ will respond with the high scores table for the given level or episode. +- `search solo userlevels for "house" by "yefffef" order by -favs` +- `browse hardest userlevels sort by date` -'level' can be a level or episode ID (eg. SI-A-00, SI-A-00-00) or a level name (eg. supercomplexity). +This command, and many others, has components (buttons and select menus) to allow for better navigation and paging once the command has been issued. -This will also force inne++ to update the scores with the latest results from N++ (rather than waiting for the default half-hour between updates). +### Mappack support -### Display time until a new episode or level -- *when's the next lotd* -- *when's the next eotw* +A mappack is a cohesive set of userlevels made by one or more members and published in the community under much fanfare. These maps replace part of the original vanilla campaign (often the intro tab, although larger mappacks do exist), and provide a fresh playing and highscoring experience for those than want more out of the game. It is recommended that a mappack always be played with a new savefile. -inne++ will respond with a rough estimate of how long until a new level or episode is posted. +These mappacks are published in the [custom-tabs](https://discord.gg/E55W3qhBqW) forum of the N++ [Discord server](https://discord.gg/nplusplus). We have custom installers (see [this repo](https://github.com/edelkas/npp_mappacks)) that perform all the necessary patching for Windows, including but not limited to: -### Identify yourself -- *my name is \* +- Replacing the map files. +- Automatically swapping the savefile. +- Enabling custom leaderboards by redirecting your game to our custom 3rd party server. +- Implementing additional playing modes (speedrun and low-gold). +- Optionally installing new custom palettes. +- And some nice cosmetic changes to go along with the rest. -inne++ will save your username, so you don't need to specify it when looking up high score data with the following commands. -For any command that requires a username, if you don't specify a username, inne++ will use this one. +Mappacks are supported by the bot, and thus many of the aforementioned commands can be issued for mappacks as well (e.g. scores, rankings, screenshots, lists, etc). All mappacks have an associated 3 letter code. In order to specify a mappack, the code must be included somewhere in the command (e.g. `ctp rank`). -### Display rankings -- *rankings* -- *level rankings* -- *rankings with ties* -- *point rankings* -- *score rankings* -- *\ rankings* -- *top 10 intro level rankings with ties* -- *average points rankings* -- *average rank rankings* +Use the `mappacks` command to get a list of all currently supported mappacks and their information. The backend of this bot also serves as the 3rd party server the patched games connect to for mappack support. -inne++ will compute the overall number of top-N scores, total points, total score, average points, or average rank, for every player, and display the top 20. +### Regular events -If 'with ties' is specified, inne++ will consider a tie for the score to count towards the rankings, even if that means taking more than N rankings for the level. +There are several events or tasks which take place regularly during inne's operations, most often daily: -If neither 'point', 'score', 'average' or a rank is specified, inne++ defaults to 0th rankings, no ties. +- The userlevel database gets updated every **5 minutes** for new userlevels. +- The entire highscore database for Metanet scores gets updated once **daily**. This means that you may need to wait up to a day for all changes to reflect in rankings and statistics. An individual leaderboard will also be automatically updated if you manually query the scores with the `scores` command. +- The highscores for the newest 500 userlevels get updated **daily** in the database as well. Older userlevel scores get continuously updated in the background constantly and more slowly: it takes about 2 weeks for a full round trip across the currently published 100k+ userlevels, unless you manually query the scores. +- Every day a new Level of the Day (lotd) gets published in the `#highscores` channel, and the highscoring activity for the previous one gets summarized, to encourage competition. This currently happens 2 hours after the highscore update. +- Every sunday a new Episode of the Week is published. +- Every 1st of the month a new Column of the Month is published. +- The highscoring report is published **daily** in the `#highscores` channel, right after the new lotd. This summarizes information about the highscoring activity in the past 24h and in the past week. +- The userlevel report is published **daily** in the `#userlevels` channel. This summarizes highscoring activity (mainly 0ths and points) _in the newest 500 userlevels_. +- The [N++ category](https://www.twitch.tv/directory/category/n-2015) in Twitch is checked **every minute** for new N++ streams, which are posted automatically in the `#n-content-creation` channel. You can also use the `@Voyeur` Discord role to be notified (pinged) of this. -### Display scores with the largest or smallest spread between 0th and Nth -- *spread* -- *spread 19th* -- *smallest spread* -- *level spread* -- *\ spread* -- *biggest intro level spread 10th* +Some other regular events are omitted here due to their technical nature, such as monitor memory or database status. See the [Developer](#for-developers) section for more details. -inne++ will display the episodes or levels with smallest or largest spread between 0th and Nth. -If a number (eg. 10th, 19th) is not specified, inne++ defaults to spread between 0th and 1st. +### More advanced options -If 'smallest' or 'biggest' is not specified, inne++ defaults to 'biggest'. +Most commands support many additional options. For a full reference, check the relevant `help` command, or if documentation is not yet available, ask a member in the server. For instance: -If 'episode' or 'level' is not specified, inne++ defaults to 'level'. +- In highscoring commands, results can often be filtered by tab (SI, S, SL, SU, ?, !) and type (Level, Episode, Story), in any order. Thus, one can ask `how many top5 * ? ! level`, which will return how many level top5's the identified player has in the secret tabs (`? !`) which used to be 0th (`*`). +- Furthermore, many commands support filtering by mappack, and even playing mode. Thus, one can say `ctp sr score si rank`, which will rank players (`rank`) by total score (`score`) in speedrun mode (`sr`) in the intro tab (`si`) of the CTP mappack (`ctp`). -### Display maxed or maxable levels or episodes -- *maxed* -- *maxable* -- *\ maxed* -- *\ maxable* +### Tips and tricks -'Maxed' levels are levels with 20 or more ties for 0th. Often this levels can't be improved unless innovated, hence the name. inne++ will display a list of all such levels (or episodes, if asked). +Here are some nifty features to make using the bot simpler: +- If you identify, you will be able to omit your player name in many commands. You can do this with the command `my name is PLAYERNAME`, changing `PLAYERNAME` with your actual N++ username. Now you can run `how many top20` and it will tell you _your_ top20 count. +- Similarly, you can specify a default palette with `my palette is PALETTE`, which will be used by default when generating screenshots or animations. +- Whenever you need to specify a name (player name, level name, palette name, etc) you can always put it at the end using the prefix `for` (e.g. `scores for the basics`), but you can also use quotes, which will help when there are multiple such terms. For instance, `browse userlevels for "house" by "yefffef"` will search for all userlevels by the author `yefffef` having the word "house" in their title. +- You can refer to levels in up to 3 different ways: the ID (e.g. `S-C-19-04`), the name (e.g. `nonplusplussed`) and the alias (e.g. `-++`). Aliases are added manually by the botmaster. Player names can also have aliases. +- There is fuzzy searching for level names and palette names, which means that you can enter incomplete or approximate names and the closest matches will the found. If there's a single one, that one will be used. If there are multiple good matches, the list will be printed. +- You can shorten level IDs by omitting the dashes and extra zeroes if there's no ambiguity. For instance, level `S-B-01-03` can be referred to as `sb13`. In case of ambiguity, levels take precedence, so episode `S-B-13` cannot be shortened to `sb13`, because it would be confused with the previous level. In this case, the dashes are needed. -On the other hand, 'maxable' levels are levels with many ties for 0th, hence being potentially unimprovable. inne++ will display a list of the 20 levels with the most ties for 0th, in descending order. +## For developers -As usual, you can filter by tabs. +Anyone looking forward to run their own fully featured version of outte, or even start contributing to it in a meaningful way, is advised to contact me through [Discord](https://discord.gg/nplusplus), as the initial setup process is complex enough to warrant it. Nevertheless, here are some pointers and things to look for. If you haven't read the section [For Users](#for-users) I recommend to do so first. -### Display cleanest or dirtiest episodes -- *cleanest* -- *dirtiest* -- *\ cleanest* -- *\ dirtiest* +### Requirements -inne++ will display a list of the 20 episodes with the least difference between the episode 0th and the sum of all of its 5 level 0ths (for 'cleanest'), or the ones with the most difference ('dirtiest'). As usual, you can filter by tabs. +- [Ruby 2.7](https://www.ruby-lang.org/en/downloads/) or higher. I did most development there, and recently migrated to Ruby 3.3 while making sure to preserve backwards compatibility. If you need to use multiple versions of Ruby simultaneously for other projects, I recommend using [rbenv](https://github.com/rbenv/rbenv). +- [MySQL 5.7](https://dev.mysql.com/downloads/) or higher. Again, that's what I used until recently migrating to MySQL 8.0 while preserving backwards compatibility. For configuration, make sure to use `utf8mb4` for both encoding and collation, either server-wide or at least for outte's database. I recommend to use the [my.cnf](https://dev.mysql.com/doc/refman/8.4/en/option-files.html) configuration file provided in [./util/my.cnf](https://github.com/edelkas/inne/blob/master/util/my.cnf) for a tested configuration. +- [Python 3](https://www.python.org/downloads/) for some auxiliary tools, notably SimVYo's [nclone](https://github.com/SimonV42/nclone) to simulate N++'s physics engine and trace or animate runs. Technically this is optional, if you don't have it you'll have to disable `FEATURE_NTRACE` (see [here](#configuring-the-bot)). +- A **Discord bot** account. A bot is simply a particular type of application you can have associated to your Discord account, you can create and configure it in the [Developer Portal](https://discord.com/developers/applications). You'll need to get the bot invited to the server in order to have it authorized to operate, just like any other user. There are many tutorials for all this online. Finally, you'll need to take note of the _Application ID_ (also known as the _Client ID_), which identifies your bot, and the _Token_ (also known as the _Client Secret_), which authenticates it. Needless to say, this last one is secret and should never be shared publicly, _nor included in the code base of inne_. +* **Optionally but recommended**: I've done all development on _Linux_, and a few minor things are actually dependent on it (such as memory monitoring or SHA1 hashing). The rest should work (and those things could be adapted), but I haven't tested anything there in years. When on Windows, I develop it via [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). The bot itself is hosted in a Linux server I connect to via SSH (not covered here). -### Display episode ownages. -- *ownages* -- *\ ownages* +### Configuring your environment -An episode ownage occurs when the same player has the 0th for an episode and all of its 5 levels. inne++ will print a list of all ownages. As usual, you can filter by tab. +First you'll need to ensure you have all required gems (libraries) installed. If you have bundler installed, it should suffice to run `bundle install`, this process may take a while the first time. If you don't, install it first with `gem install bundler`. Some gems have prerequirements, such as ImageMagick, which you'll have to deal with, so don't expect this process to be seamless! -### Display the community's total scores -- *community* -- *\ community* +Then, you'll need to create a few environment variables to hold the private login information, which should never be directly in the code base. Those marked with * are required, the others are optional. -The community's total scores are the sum of all 0ths. inne++ will display some information regarding the community's total score, like the total level score, total episode score, the (adjusted) difference between both (to see, globally, how clean episodes are), and also, the averages per level of all of them. As usual, you can filter by tab. - -### Find a level ID by level name -- *level id for \* +Variable | Content +-------- | ------- +`DISCORD_CLIENT`* | The _Client ID_ of your bot account. +`DISCORD_TOKEN`* | The _Client Secret_ of your bot account. +`DISCORD_CLIENT_TEST` | Client ID of your alternative, testing bot. +`DISCORD_TOKEN_TEST` | Client Secret of your alternative, testing bot. +`TWITCH_CLIENT` | Client ID for Twitch authentication. +`TWITCH_SECRET` | Client Secret for Twitch authentication. +`NPP_HASH` | Secret password to compute integrity hashes. -inne++ will tell you the level ID for the specified level (eg. S-D-15-04 for 'supercomplexity'). +#### Notes: -### Find the name of a level by ID -- *level name for \* +- Having a **test bot** is of course not necessary if you're just going to contribute, but it is for me, so that I can develop on a bot that's not the one currently running in the N++ server. -inne++ will tell you the name of the specified level (eg. 'supercomplexity' for S-D-15-04) +- If you're not interested in having **Twitch** functionality (which basically notifies of new N++ streams), simply toggle off `UPDATE_TWITCH` (see [here](#configuring-the-bot)). -### Display your points -- *points* -- *level points* -- *\ points* -- *intro level points* +- If you're not going to run inne as a 3rd party server for mappack support, you don't need the security hash either (and even then, you can turn them off by toggling `INTEGRITY_CHECKS`). -inne++ will tell you how many points you have. Points are computed by giving 20 for 0th, 19 for 2nd, etc. -### Display your total score -- *total* -- *\ total* +### Configuring the database -inne++ will tell you your total score (see 'total score rankings' above). +By default, outte will connect to the MySQL database named `inne` using the configuration parameters from the [./db/config.yml](https://github.com/edelkas/inne/blob/master/db/config.yml) file. You can set these up as you need, although I wouldn't recommend changing the connector, encoding/collation, and timeout parameters, unless you know what you're doing. Look into the `DATABASE` variable (see [here](#configuring-the-bot)) if you want to have multiple database environments configured. -### Display your top N count -- *how many* -- *how many 0ths* -- *how many with ties* -- *how many \* -- *how many intro top 10s with ties* +Seeding the initial data in the database so that it's ready for operation is not trivial, and I haven't done it in years. Therefore, the recommended way to get it is to just ask me for a database dump you can import. However, if that's not possible or you want to do it yourself, follow these rough steps: -inne++ will display the number of top-N scores you have. +- **Create** a new database named `inne` (by default) using `CREATE DATABASE inne`. +- Run all the **migrations** using `rails db:migrate`. This will run dozens of migrations, which essentially create the tables in the database and configure them. Unfortunately some migrations which I haven't run in years might fail and you'll have to debug them. +- **Seed** some more initial data into the database using `rails db:seed`. This will initialize some records in the database, such as the stuff that was introduced in UE. -If no rank is specified, defaults to 0ths. +I've seeded plenty of data directly in the migrations, however, which is bad practice. Ideally, migrations should only affect the database schema (the structure, tables, configuration, etc), and inserting initial data and records should be done in the seeding. This method is more future-proof. Cleaning this up would be nice for the project, but alas, you'll have to deal with it for now. -### Display your high score stats -- *stats* -- *\ stats* +### Configuring the bot -inne++ will display the total number of level and episode high scores for the specified user, broken down by rank, and also a histogram of the player's scores. +Finally, there are hundreds of variables in the [./src/constants.rb](https://github.com/edelkas/inne/blob/master/src/constants.rb) file you can modify to properly configure the bot. This file is decently well documented. These are some of the most crucial ones you may want to pay attention to at the beginning: -### Display your most improvable scores -- *worst* -- *worst 20* -- *worst 20 episodes* -- *worst 20 \* -- *worst 20 intro levels* +Variable | Description +-------- | ----------- +`TEST` | Toggles between production and development bot. +`DO_EVERYTHING` | Perform all [background tasks](#regular-events). +`DO_NOTHING` | Don't perform any background task. +`BOTMASTER_ID` | Your Discord ID, to manage the bot. +`SERVER_ID` | The Discord server ID where the bot resides. +`SERVER_WHITELIST` | Servers allowed to be connected to. +`SUPPORTED_COMMANDS` | Enabled slash commands. +`DATABASE` | The database environment to use from `config.yml`. +`HACKERS` | Keeps track of all ignored players. +`CHEATERS` | More ignored players. -inne++ will display a list of your N most improvable level or episode scores (eg. your scores which are furthest from 0th), along with the spread for each. inne++ will also display a list of N levels or episodes on which you do not have a high score. +Additionally you may be particularly interested in the following sections of the file: +- The **logging** variables, which specify how much to log to the terminal and to the logfiles. +- The **benchmarking** variables may come in handy if you're working on improving the performance of some function. +- The **task** variables are useful for enabling/disabling individual background tasks, for testing purposes. +- The **Discord** variables have some interesting settings of the bot's behaviour. -If a number isn't specified, inne++ defaults to 10. +### Building the C native extension -If neither 'levels' or 'episodes' is specified, inne++ defaults to level scores. +The project includes a native module writen in C using the [Ruby C API](https://silverhammermba.github.io/emberb/c/) (also see the [official docs](https://github.com/ruby/ruby/blob/master/doc/extension.rdoc)), the code is located in the [ext](https://github.com/edelkas/inne/tree/master/ext) folder. This API allows to communicate directly with the underlying C implementation of Ruby, greatly speeding up most tasks. -### Display a list of levels you haven't high scored -- *missing* -- *missing 0ths* -- *missing episodes* -- *missing \* -- *missing intro level top 10s* +To build it, it suffices to run the [build.sh](https://github.com/edelkas/inne/blob/master/ext/build.sh) script. Alternatively, you can generate the Makefile with [extconf.rb](https://github.com/edelkas/inne/blob/master/ext/extconf.rb), then _Make_ it manually, and finally copy it to the `/lib` directory (not present in the Github repo because the `.so` file is git-ignored). -inne++ will send you a text file containing all levels and episodes you're below the specified rank on. +When the module is required in Ruby it calls the entry point function `Init_cinne()` defined in [main.c](https://github.com/edelkas/inne/blob/master/ext/main.c). You'll know it's been properly included if the global constant `C_INNE` is defined. Nevertheless, it should be optional, as all functions have Ruby alternatives, but it's highly recommended. -If a rank isn't specified, inne++ defaults to top 20. +### Final remarks -### Display a list of all your high scores -- *list* -- *0th list* -- *top n list* -- *bottom n list* -- *top m bottom n list* +Make sure to: -inne++ will send you a text file containing all level and episode high scores for the specified user listed by rank. You can filter only the 0ths, or only the top or bottom ones. You can use both options at the same time to obtain only the score between 2 ranks of your choice. As usual, you can filter by tabs. +- Edit and save all source files in **UTF8**. +- Run the bot directly from the root directory of the repo, not from the `src` one, or any other one (the relative paths may then fail). -### Link a video of a level or episode -- *video for \* -- *video for \ by \* -- *\ video for \* -- *\ video for \ by \* +To conclude: I've documented here the major steps of the process to get started, but I'm sure I've missed other minor things, as I haven't had to start from scratch in years. This means the process is likely going to stump you in more than one place, so don't hesitate to contact me over at the [Discord server](https://discord.gg/nplusplus). -### Analyze replays -- *analysis for \ \ \ ...* +## Credits -inne++ will download and analyze the inputs of a group of runs with ranks *\*, *\*... from level *\*. You can introduce as many ranks as you want, as long as they are separated by spaces. You need to introduce the level id (e.g. S-A-15-03) rather than its name (e.g. "neo tokyo") to avoid confusing inne with the also introduced ranks. - -### Initialize inne++ -- *hello* -- *hi* - -When inne++ first joins a channel, in order to start sending levels and episodes of the day, you have to say hi. Note that if you run this in a private message before you run it in the channel, inne++ will send the levels/episodes to your PMs instead of the channel, so don't do that ;) - -### Display help -- *help* -- *commands* - -inne++ will send you a short form of this README file. - -## License and attributions -This project is licensed under the terms of the MIT license (see LICENSE.txt in the root directory of the project). - -Special thanks to jg9000, eru_bahagon and EddyMataGallos from the N forums for their work on NHigh (https://forum.droni.es/viewtopic.php?f=79&t=10472), which inspired most of the high-scoring features in inne++, and provided key guidance on using the N high scores API. - -And thanks to the following contributors: -- EddyMataGallos (@edelkas) -- Toasters (@andrewhuntsmith) -- poober (@jseakle) -- systeminspired +This bot was originally developed and maintained by [@liam-mitchell](https://github.com/liam-mitchell), check out the [original repo](https://github.com/liam-mitchell/inne). It built on ideas developed for the previous iteration of the game, [N](https://www.thewayoftheninja.org/n.html), notably [NHigh](https://forum.droni.es/viewtopic.php?f=79&t=10472) by jg9000, which was focused solely around highscoring. \ No newline at end of file diff --git a/Rakefile b/Rakefile index e1aad70d..5109508e 100644 --- a/Rakefile +++ b/Rakefile @@ -1,11 +1,17 @@ require 'active_record' require 'database_cleaner' -require 'factory_girl' +require 'factory_bot' +require 'rails' require 'yaml' require 'yaml_db' -require_relative 'models.rb' -require_relative 'messages.rb' +require_relative 'src/constants.rb' +require_relative 'src/utils.rb' +require_relative 'src/io.rb' +require_relative 'src/models.rb' + +# Supress warnings, too spammy in migrations! +$VERBOSE = nil module Rails def Rails.env @@ -14,53 +20,71 @@ module Rails end namespace :db do + desc "Sets database environment and migrations directory" task :environment do - DATABASE_ENV = ENV['DATABASE_ENV'] || 'development' - MIGRATIONS_DIR = ENV['MIGRATIONS_DIR'] || 'db/migrate' + DATABASE_ENV = ENV['DATABASE_ENV'] || DATABASE + MIGRATIONS_DIR = ENV['MIGRATIONS_DIR'] || DIR_MIGRATION end + desc "Loads database connection configuration from YAML file" task :configuration => :environment do - @config = YAML.load_file('db/config.yml')[DATABASE_ENV] + @config = YAML.load_file(CONFIG)[DATABASE_ENV] end + desc "Establish connection with database using YAML configuration" task :configure_connection => :configuration do ActiveRecord::Base.establish_connection(@config) end + desc "Alias for 'configure_connection'" task :create => :configure_connection do ActiveRecord::Base.establish_connection(@config) end + desc "Deletes the database" task :drop => :configure_connection do ActiveRecord::Base.connection.drop_database(@config['database']) end + desc "Execute new migrations" task :migrate => :configure_connection do - ActiveRecord::Migrator.migrate(MIGRATIONS_DIR, ENV['VERSION'] ? ENV['VERSION'].to_i : nil) + version = ENV['MIGRATION_VERSION'] ? ENV['MIGRATION_VERSION'].to_i : ENV['VERSION'] ? ENV['VERSION'].to_i : nil + if rails_at_most('5.2.0') + ActiveRecord::Migrator.migrate(MIGRATIONS_DIR, version) + else + ActiveRecord::MigrationContext.new(MIGRATIONS_DIR).migrate(version) + end end + desc "Rollback latest migration, or a fixed number of them" task :rollback => :configure_connection do - ActiveRecord::Migrator.rollback(MIGRATIONS_DIR, (ENV['STEP'] || 1).to_i) + if rails_at_most('5.2.0') + ActiveRecord::Migrator.rollback(MIGRATIONS_DIR, (ENV['STEP'] || 1).to_i) + else + ActiveRecord::MigrationContext.new(MIGRATIONS_DIR).rollback((ENV['STEP'] || 1).to_i) + end end + desc "Seed database with initial records stored in seeds.rb" task :seed => :configure_connection do - require_relative 'models.rb' - require_relative 'db/seeds.rb' + require_relative "#{DIR_SOURCE}/models.rb" + require_relative "#{DIR_DB}/seeds.rb" end + desc "Run tests" task :test => :configure_connection do - require 'test/unit' - require 'test/unit/ui/console/testrunner' + require "#{DIR_TEST}/unit" + require "#{DIR_TEST}/unit/ui/console/testrunner" require 'mocha/test_unit' class Test::Unit::TestCase - include FactoryGirl::Syntax::Methods + include FactoryBot::Syntax::Methods end - FactoryGirl.find_definitions + FactoryBot.find_definitions - require_relative 'test/test_models.rb' - require_relative 'test/test_messages.rb' + require_relative "#{DIR_TEST}/test_models.rb" + require_relative "#{DIR_TEST}/test_messages.rb" DatabaseCleaner.strategy = :transaction diff --git a/TODO b/TODO new file mode 100644 index 00000000..59165977 --- /dev/null +++ b/TODO @@ -0,0 +1,284 @@ +- Blacklist should probably be handled in the db, not in the source. Also, blacklisted names should be removed. +- "screenshot palette "classic" s-e-05-04" triggers CLA mappack +- Video library: Account for players with multiple videos in the same highscoreable. Also, account for notes. +- Overlay texts over screenies via RMagick (e.g. search results) +- When an ID is detected, but no command matched, we can show generic info about that highscoreable, including a row of buttons with different common functions: scores, screenshot, analysis, trace, anim, challenges, videos... +- Run update_completions regularly, perhaps daily, or even better use that account for the daily scores update, and report new scores in the daily summary. +- Detect prolongated Discord websocket breakages automatically and restart or something. +- Add reminder for Steam refresh token expiration a few days prior +- API: Build cache in memory for commonly accessed values (e.g. last archive IDs, results of analysis, dumps, traces, etc) +- Sanitizing archives will actually remove outte++2 players, leading to server requests failing. Perhaps we should separate it to a different category so that it's blacklisted from score archival but the player itself isn't deleted. +- For average lead rankings, filter players based on number of 0ths, not total scores, otherwise a player with 1 0th can easily top them +- Compute stars and cools on the fly when using archives. +- Add dupe player removal in sanitization function, this requires carefully adjusting the player_id of the removed players in all tables that refer to it. +- Add color diffs when navigating scores. +- We could scrap all replays, and regularly scrap the new ones given the IDs. Then we can: + * Recover most scores using NSim. + * Recover level scores from episode replays, again using NSim. + * Get player names from Player table, UserlevelPlayer table, and UserlevelAuthor table. +- For the logs we shouldn't be reading the whole file and painting all lines every time we browse a 10 line page. +- Add ephemeral messages in useful places (e.g. a button that says "Screenshot" when querying scores or viceversa) +- Ability to subscribe to certain things, like new maps by some user, or lost 0ths/top20s in vanilla/mappack/userlevels, or any new 0th, ... +- Function to sanitize mappack archives by removing dupes. +- Add nv2 converter. +- Add Userlevel -> Metanet and viceversa converters. +- Reload Discordrb main branch from bundler, which allows to respond to interactions with attachments. Use this to implement screenshots when searching multiple pages of levels, or even when browsing userlevels. To prevent regenerating the screenshots when browsing, cache them in /tmp using some kind of hash for the name to identify the query, and clear them regularly (and when there are too many). +- Persist task counters in db (using task_ prefix). Don't do it constantly, perhaps only sum them when shutting down. +- Migrate highscoreable_type to integers from 0 to 2. +- Add a special command to run arbitrary Ruby code, sent from Discord, and print the return inside a text block. Ensure to execute inside guards for exceptions. Perhaps even code a simple Debugger that can be started in a separate Thread, which waits for commands (e.g. using a Queue). +- Add Interaction module to handle them, now the code is too unorganized. +- Add robust test suite that automatically tests as many outte features as possible. At least that they execute without throwing, even better if we can assert equality in some cases. +- For when we enforce hashes: Have secondary db table to temporarily store rejected runs due to invalid hash, just in case they're legit and need to be restored +- Add indices to many tables, apparently add_column does not have an :index option (as opposed to create/change_table, which does), so many of the migrations we used the last years failed to create an index at all. Go table by table, seeing what's useful to add, and benchmark the differences. +- Add a live control panel (a special command that puts a collection of buttons and perhaps select menus and modals on Discord, that allows to execute managerial functions, like batch update the completions (as well as cancelling them)). +- Revamp help / commands, with navigation buttons, select menus, etc. Use classes for each command, which will contain all the info necessary to print the corresponding window of info. Then, before each command in messages.rb we can also create the corresponding help object, to keep things organized (or just in a new file). The help (categories, command lists, help texts, examples...) should be autogenerated. Then remove all that from the README file. +- Distribution of scores: top20 episode distribution = first show some stats regarding top20s per episode (most top20s in an ep, least, average, etc), then a file with all episodes sorted by # of top20s). Parameters: type, rank (top20, 0th, etc), distribution type (by episodes, by rows, by tabs). +- Restrict display names to ASCII only +- Implement "index" (maybe "tally"): Outputs file with a list (like "list") of something, like all 0ths. With a parameter we specify what, for instance, 0th scores or 0th frames. With another parameter we specify the order, like the natural one, or by score in descending order... +- Compare local scores (nprofile) vs server scores to find differences, a la NReality Score Comparer. +- Add total score, and show total score and total 19th score gained daily and weekly. +- Screenscores for userlevels. +- Add "maxed" and "maxable" to "list" and "how many". +- Add "ownage" and others to "table". +- Perhaps add rank to how many function, i.e. "username has n/N top20 scores (ranked kth)". +- Report: Add # of 0ths, i.e., "• There were 23 new scores by 9 players in 18 levels, making the boards 15.083 seconds harder including X new 0ths increasing the total 0th score by 1.050 seconds." +- Save how many times each streamer has streamed the game, and perhaps indicate it somehow if it's among the top ones (dope gamer alert!) +- Command to update certain userlevels (e.g. within a range, from a author, etc., with some hard limit e.g. 50, perhaps a cooldown). +- Add other versions? (v1.4, v2.0, etc) +- "nubs" command to show ignored hackers and cheaters +- Allow to add "rankings" to any userlevel query (browse) to instead perform rankings on the result set. +- Add regex to functions (userlevel search, etc) +- aliases: Make sure they cannot be repeated. Add edit/remove alias functions only for botmaster. +- Personal demo analysis function (for when a run is not in the global leaderboard) +- Browse uncleared userlevels (levels with no completions) +- Unmaxed missing (ignore maxes in missing) +- Add "graphic" / "image" parameter to many commands which output level lists, so that instead of the list, we get images with thumbnails of 5/10 levels, with arrow button navigation to move, a-la userlevel browse. +- Add access to all functions using slash commands +- Multiple matches: + * Add for player names + * kk's suggestion: nav bar for matches, navigate screenies etc +- Userlevel tabs not working correctly? (dupped levels in top weekly, what about featured, etc) +- Handle cheaters within database (rather than via hardcoding in source code), have botmaster function to add cheaters, etc +- Histograms of userlevels played by ID (perhaps also chart anything, e.g. 0ths, top10s, parse_range) +- Analysis: Implement for episodes and stories and userlevels and mappacks +- "percent" for table +- maybe modify function 'nav' from 'HighScore' module to allow for arbitrary offsets, and have some special sign to denote 'first' and 'last' +- add "full" to everything that outputs lists (missing, etc), maybe nav too +- add "suggestions" command (random selection of scores the player doesn't have) +- allow "missing" parameter in table, just like in "how many" or "list" +- use Score.filter with a Player for some of the Player methods, like range_ns, table... +- add Score::filter to rank_exclude +- CKC rankings weighed by the number of cool kids in each board +- Have all people tied in rankings have the same rank, with hyphens. Show slightly over 20 if tied at the bottom, up to another limit, like 25. +- In order to sort ties, rather than alphabetical, use chronological. For simplicity, use date of most recent 0th, rather than the whole history. +- Migrate the scores table to using integer scores (in frames), like the others. +- Dual 0ths (both hs and sr 0ths), and dual ownages +- Package outte as a gem +- Across-mappacks rankings and stuff +- TASes in outte, perhaps add to CLE too (scores around mine?) +- String distance for player/author names (and also partial matching), and maybe even outte commands +- Generated nprofiles have extra challenges unlocked? +- Add generic nprofile functionality +- Can we track Youtube N++ streams like with Twitch? +- Ownages with ties (add "with ties"), also add to mappacks +- Minimum scores to appear in mappack average rank rankings are 0, fix. +- Create a class QueryResult for when we parse a highscoreable, that contains the array of results, the header of text, a function to format the result, etc. Perhaps do the same for parsing players and other things. +- Add function to update stars in scores table, since due to errors we could sporadicaly lose some of them, but we can rebuild them by looking at the archives table. +- Use average rank for tie-breaking rankings +- For mappack ranking navigation, add button for HS/SR, and perhaps select menu for mappacks +- Add other most/least gold functions apart from rankings: how many, missing, etc +- Player comparison for mappacks (add speedrun, G++/G--, ...) + +\\ ------------------------ SQL ----------------------- + +General tips: +- https://dba.stackexchange.com/questions/311593/mysql-indexes-on-all-column-combinations + +Indices: +- https://mysql.rjweb.org/doc.php/index_cookbook_mysql + +Partitioning: +- https://mysql.rjweb.org/doc.php/partitionmaint + +Memory tables (for userlevels, read-only): +- https://dev.mysql.com/doc/refman/8.0/en/memory-storage-engine.html +- https://stackoverflow.com/questions/10692398/how-do-i-make-a-mysql-database-run-completely-in-memory + +Find a way to forcefully clear dead connections. Perhaps MySQL server needs to be restarted in extreme cases. + +Monitor SQL connections. Add warnings when the connection count gets close to the limit, deal with by: + Increasing the limit. + Restarting outte, like we do with the memory limit. Or even MySQL. + +Save number of concurrent connections each time we monitor, so that we have a registry and can e.g. plot them later, to see the average num of connections, peaks, etc. + +Perhaps we should look into using acquire_connection (and release_connection) in _thread, and make sure we use that wrapper everywhere. + +\\ ------------------------ speedrun api ------------------------ + +\\ ------------------------ slash commands ---------------------- + +- User-specific commands: + * Global userlevel summary (both highscoring and mapping). Add some additional features here and format nicely (table, colors, headers, etc). + * Stats. + * Table. + * Separate function for histogram and other kinds of graphs. + * Some kind of history function? +- Message-specific commands: + * Re-run command (even if it didn't contain an outte ping). + * Delete msg (if the usual conditions are met). + +\\ ------------------------ mappacks ----------------------- + +- Add outte command to choose what to put in the 2nd/3rd leaderboard (toggle between hs, sr, g--, etc, with different rank ranges too) + +\\ ------------------------ frac scores ----------------------- + +- At the moment the final position for the interpolation routine isn't actually taken at the end of the tick function, but during post collision, i.e. after physical collisions but potentially before some logical collisions and before the thinking, which in particular takes care of the jumping logic. This can cause the ninja to suddenly start the next frame already inside the exit door, leading to a fractional frame of exactly 0 and thus poor interpolation. +- Only store the fractional frame when colliding with the exit door if the current fractional frame is either still unset or is larger than the new one (the lower the better). That way we handle the case where we collide with 2 exit doors on the same frame. +- Consider edge cases, such as exit switch inside exit door. At the moment, since we interpolate with respect to the position of the exit door, these cases are useless. But this is tricky, because in that scenario the game actually finishes on the next, and so the interpolated score would be higher than the real one. +- Instead of returning the fraction field separately in the .leaderboard methods, perhaps we could integrate it so that they already return the scores adjusted by the fractional part. Careful with nulls though. +- Add frac scores to nav scores function. +- Return scores closest to making it to the next frame, or closest to the current frame (i.e. fractions closest to 0 and 1). Perhaps allow to limit this to framewars, where it's more interesting. + +\\ ------------------------ ntrace ------------------------ + +# Traces +- Option to change color of plot line with time (let's say, loop through rainbow as many times as specified, or once), to aid readability for long runs with many crosses. +- Look into matplotlib blitting, or implement it myself +- Try using Python itself, rather than Ruby's wrapper, to see if it's quicker +- Try optimizing it in C altogether + +# Anims +- When animating entities in runs with multiple players, instead of taking the fastest player's entities only, we should take the slowest's, or perhaps combine them (after the fastestfinishes, the other continues). Otherwise, the entities stop moving when the run is still going for the slower ninjas. +- Anim scores +- For multiple players, perhaps color gold differently depending on how many players have collected / remain to collect each piece. +- Precompute bounding boxes for all frames, and use this to estimate how long the encoding might take, then decide if it's worth it to update the Discord message with progress information, such as frames rendered or percentage done. +- Remove level name from msg, so that it may not interfere with other parameters of the command. +- Ensure each trace is always drawn over the previous one, by adding a condition that will not paint a pixel if it's already been painted by a previous trace. +- Interpolate between frames, so that we can trace at slower speeds at 50fps, or basically at any speed. +- Other ideas: anti-aliasing, bezier curves, frame counter / timer, controls for which segment of the run gets animated (instead of the whole thing), more than 4 players, changing line colors (perhaps to reflect speed or direction changes), ... + +\\ ------------------------ nprofile ------------------------ + +- Create an nprofile module / class to handle different functionality +- Upload zipped nprofile and perform stats (e.g. graphs with svg-graph) +- Allow multiple nprofiles to perform cumulative stats (e.g. attempts or times) + +\\ ------------------------ HC ------------------------ + + +\\ ------------------------ Art ------------------------ + +- Use different objects for different colors. +- Create a custom palette that best fits the image. +- Dithering. + +\\ ------------------------ Userlevels ------------------------ + +- Display names and implicit player parsing do not work for userlevels now. Merge userlevel_players (and possibly userlevel_authors) with players: + * Add metanet_id column to userlevel_scores + * Merge userlevel_players (not already in players) with players + * Fill in proper player_id in userlevel_scores by looking at the metanet_id + * Ditto for userlevel_histories + * Remove metanet_id field from userlevel_scores and userlevel_histories + +- Turns out you can see Switch players real username in Search (but not in regular tabs like Newest). Perhaps we can exploit this to fill the blank names in the db by searching for all their maps and, if we get a single result, we know it's the correct player. +- When the name is still empty, show ID instead. +- Userlevel of the day (filter by levels with at least n scores and at least a certain delta of scores to prevent impossible and maxed levels) +- Add Metanet levels to the db. +- When parsing the userlevel author, prioritize exact matches over partial ones, so that if there's a single exact match and multiple partial ones, we use the exact one instead of showing the full list of partial ones. +- Short/compact search mode (apt for mobile; show only ID, name and author; shorter truncations) +- Missing +- NUMA-esque features: We could have a command (e.g. "post") that prints the userlevel info (name, author, screenshot, etc) and adds buttons to cast a vote (we keep track of the users so that a single vote can be casted per Discord user), as well as a text input to put a comment (or perhaps, save a conversation regarding a userlevel as the "comment section" of that map, somehow), ec +- Demo analysis. +- Userlevel rankings by page (featured, best, hardest, etc.). Also, the special hardest rankings with point ponderation. +- Imagesearch: Show pictures of results (miniature) instead of text. +- Userlevel stats: How many unique authors, maps, averages, etc. Break down by mode and total. +- When an ID is not in the database, if it's close (or always), download newest, but without printing it. +- Store a daily list of all top 50 userlevels (just ID's for instance), so we can see how the hardest levels evolve. +- Browse user dependant categories +- Browse other (made up but interesting) categories +- Download maps (plural), like, all maps by an author or something. +- Upload maps to accounts (need to figure out request by using proxy first) +- Map editor with slots for each user +- Comparison function (like for Metanet scores). Be able to filter maps by author, name, etc. + +Others: +- Add the possibility of making secondary orders (to break ties). + +\\ ------------------------ Scores ------------------------ + +- Ping players who lost 0ths, using the previous function. + +\\ ------------------------ Screenshots ------------------------ + +- Optimizations: + * Draw all tiles using polygon (in OilyPNG) rather than compose (and bench it) + * Initialize the canvas in C + +- Implement the horizontal auto-centering. Essentially if one side has more filled (E tile) columns than the other, the game shifts the level half a tile in that direction. +- Implement vector silhouettes (would need one more layer per object, and perhaps new colors in the master image, make them optional with a parameter to the screenshot function) +- Allow to compose images out of bounds by cropping them appropriately. Make a commit to oily_png, like the original author intended. +- Show glitch tiles with some soft shade, e.g. red or the inverse of the background, perhaps with a pattern too, e.g. lines or dotted. +- Properly rotate laser turrets when they're against a tile +- Draw lasers, even if in basic fashion (straight lines till tile) +- Optionally show editor info in the screenshot (door-switch lines, etc) +- Minis are missing the eye (remember the second color needs to be composed in a special way to obtain the final color) + +- Implement custom palettes, and commands for users to submit their palettes, instead of editing the spreadsheet. Commands to list palettes, showcase examples, etc. +- Go through palette channel and add custom palettes that haven't been added (list zip files or something). +- Generate a gif with one palette per frame + +\\ ------------------------ Total Scores ------------------------ +- Create 2 tables: Levels and Episodes. One row per player, one column per level/episode. The idea is to keep the bot running 24/7, cycling through Steam ID's until an active one is found, and then latch onto that player and start downloading his scores. First, download those which have not yet been downloaded, and after that's done, keep downloading them, but not willy nilly, instead, also save the date at which each score was downloaded, and always update the scores in chronological order of last updated. Eventually, the goal is to have all scores by all players (in the list), and have them as updated as possible (even if they haven't completed the level). + +\\ ------------------------ Replays ------------------------ + +- Implement demo analysis to attract files. +- Compare inputs of two runs like github does, also maybe do this comparison by just specifying changes (*read below*) + +\\ ------------------------ History ------------------------ + +-- Fix the doubling through code, by halving the older scores. +-- Add the possibility of limiting the scope of graphs (upper bound of y axis), using an additional parameters to the user command (e.g. "intro history 100"). In this case, if some player doesn't even appear in the graph because he's above it, maybe delete him from the legend and add new players. This adds an aditional difficulty since it's not just changing the y axis, but also changing the players to be plotted. To do so, the maximum would have to be computed first, and then decide which players are to be plotted. +-- Medium priority: Another solution for the above problem: Use logarithmic scale. +-- Manually join players that changed username (e.g. Maelstrom=drunk driver=asleep at the wheel=coronavirus). +-- Add some chronological marks in the x axis (at least years, if not months). Maybe more and better marks in the y axis too. +-- Retroactively delete (in the code, not the database) ignored players, like Mishu. +-- Medium priority: Figure out what causes the sudden drops in the graphs, and fix it. +-- Low priotity: Select the number of players to be plotted, or the minimum number of 0ths to select such players, using user input. This would have to be limited with some hardcoded constants to prevent spam or a computation overload). +-- Low priotity: Improve legend (align players and put on the side instead of top, maybe put maxes next to them). +-- Low priotity: Overall improvements to style (frame graph, and other decorations). + +\\ ------------------------ Cheaters ------------------------ + + +\\ ------------------------ Hardcore ------------------------ + +- Cleanliness, splits, and other functions. + +\\ ------------------------ Humanlike features ------------------------ + +- Add guessing game: inne shows a picture of a level and people must figure out the level (by typing the name or level code). Maybe different difficulty settings, the easy one being a full picture and hard one being a section only, maybe ensuring that this section makes sense somehow (ie. it's not just empty space). Since we need to hold state, make sure you can run the game in a thread, with a limit of maybe 5 simultaneous games. +- Add some backstory, like what happens when outte is asked about inne (murder?) +- Add some more easter eggs, like a command that shows (or links) eddysipw, or a very small chance that a drone in a screenshot is dronexd or the ninja has eyes (see gofish meme). + +\\ ------------------------ General ------------------------ + +- SAVEFILE PARSER, ability to send inne your savefile and use it to elaborate completion leaderboards and whatnot (ask CCS if he ever finished it). +- 0th/Top20 changes (new ones, and per player). +- Charts with colors. +- Possibility to return lists in order of level and not rank. +- Add relaxed episode ownages (without episode), and ownages with ties. + +\\ ------------------------ NPC ------------------------ + +- Add 2 color boxes, to change every instance of color 1 in the palette by color 2. + + +\\ ------------------------ Notes ------------------------ + +*below* + +"holds jump for 1 less frame, switches right for 2 frames then left until end", or in oter words, "as long as the difference between two inputs is the same as in the previous frame, discard it, as you said" diff --git a/api/script.js b/api/script.js new file mode 100755 index 00000000..3df7c20f --- /dev/null +++ b/api/script.js @@ -0,0 +1,166 @@ +/* Auto-update default highscoreable ID when the user toggles the highscoreable type */ +const htype_select = document.getElementById("htype"); +const hid_input = document.getElementById("hid"); +let hPristine = true; +if (htype_select && hid_input) { + hid_input.addEventListener("input", () => { + hPristine = false; + }); + + htype_select.addEventListener("change", () => { + if (hPristine) { + hid_input.value = htype_select.selectedOptions[0].dataset.default; + } + }); +} + +/* Change demo analysis view when the user toggles the select menu */ +const analysis_select = document.getElementById("demo-analysis-view"); +const analysis_views = document.querySelectorAll("#demo-analysis-content [data-view]"); +if (analysis_select && analysis_views) { + function updateAnalysisView() { + for (const view of analysis_views) { + view.hidden = view.dataset.view !== analysis_select.value; + } + } + analysis_select.addEventListener("change", updateAnalysisView); + document.addEventListener("DOMContentLoaded", () => { analysis_select.value = 0; }); +} + +/* Kohina chiptune radio stream */ +const partyButton = document.getElementById("partyButton"); +const kohinaAudio = document.getElementById("kohina"); + +function playRadio() { + kohinaAudio.play(); +} + +function stopRadio() { + kohinaAudio.pause(); + kohinaAudio.currentTime = 0; +} + +/** + * FLOATING MOLES + * -------------- + * Displays moving floaters on the page's background bouncing on the borders. + * Floaters change their icon after bouncing on the border. + * Clicking on a floater spawns 3 more in all opposite diagonal directions. + */ + +const initialMoles = 16; +const dim = 16; +const files = ["moleBruh.png", "moleCool.png", "moleGasm.png", "moleSwole.png"] +const images = []; +const w = window.innerWidth; +const h = window.innerHeight; + +let running = false; +let frame = null; + +/* Extract a uniformly random sample from an array */ +function sample(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +/* Create a new floater on the screen with respect to another reference floater */ +function create(ref, rx = 0, ry = 0) { + const img = document.createElement("img"); + let name, x, y, dx, dy; + if (ref) { + name = ref.src; + x = ref.x; + y = ref.y; + dx = ref.dx * rx; + dy = ref.dy * ry; + } else { + name = 'img/emoji/' + sample(files); + x = Math.floor(Math.random() * (w - dim)); + y = Math.floor(Math.random() * (h - dim)); + dx = Math.floor((Math.random() * 1 + 1)) * (Math.random() < 0.5 ? -1 : 1); + dy = Math.floor((Math.random() * 1 + 1)) * (Math.random() < 0.5 ? -1 : 1); + } + const obj = { src: name, el: img, x: x, y: y, dx: dx, dy: dy }; + img.src = name; + img.width = dim; + img.height = dim; + img.className = "floater"; + img.addEventListener("click", () => { create(obj, -1, 1); create(obj, 1, -1); create(obj, -1, -1); }); + document.body.appendChild(img); + images.push(obj); +} + +/* Start animating floaters */ +function start() { + for(let i = 0; i < initialMoles; i++) create(); + frame = requestAnimationFrame(animate); +} + +/* Stop animating floaters */ +function stop() { + cancelAnimationFrame(frame); + reset(); +} + +/* Show floaters on screen */ +function show() { + images.forEach(obj => { obj.el.style.display = "block"; }); +} + +/* Hide floaters off screen */ +function hide() { + images.forEach(obj => { obj.el.style.display = "none"; }); +} + +/* Remove all floaters */ +function reset() { + images.some((obj) => { obj.el.remove() }); + images.length = 0; +} + +/* Change the icon of a given floater */ +function change(obj) { + obj.el.src = 'img/emoji/' + sample(files); +} + +/* Toggle floaters and radio */ +function toggle() { + running = !running; + if (running) { + partyButton.setAttribute("src", "octicon/square-circle.svg") + show(); + start(); + playRadio(); + } else { + partyButton.setAttribute("src", "octicon/play.svg") + hide(); + stop(); + stopRadio(); + } +} + +/* Move a given floater and bounce it on the screen's borders */ +function move(obj) { + if (obj.x + dim >= w || obj.x <= 0) { + obj.dx = -obj.dx; + change(obj); + } + if (obj.y + dim >= h || obj.y <= 0) { + obj.dy = -obj.dy; + change(obj); + } + obj.x += obj.dx; + obj.y += obj.dy; + obj.el.style.left = obj.x + "px"; + obj.el.style.top = obj.y + "px"; +} + +/* Move all floaters and animate a new frame */ +function animate() { + if (!running) return; + images.forEach(obj => move(obj)); + requestAnimationFrame(animate); +} + +/* Button controls */ +partyButton.addEventListener("click", toggle); \ No newline at end of file diff --git a/api/style.css b/api/style.css new file mode 100755 index 00000000..7a4960de --- /dev/null +++ b/api/style.css @@ -0,0 +1,250 @@ +body{ + color: white; + background-color: black; + font-size:8pt; + font-family:Consolas, monospace; + margin:0; + height:100vh; + overflow:auto; + position:relative; +} +.title{ + margin:auto; + font-size:16pt; + font-weight:bold; +} +.bold{ + font-weight: bold; +} +.list { + display: inline-block; + margin: 0px auto; + padding: 4px; + border: 1px solid #fff; +} +table.data th, table.data td { + padding-left: 10px; + padding-right: 10px; + padding-top: 1px; + padding-bottom: 1px; +} +table th.tight, table td.tight { + padding: 1px; +} +table.inputs th, table.inputs td { + padding-left: 10px; + padding-right: 10px; + padding-top: 0px; + padding-bottom: 0px; +} +TD.catValue { + padding:0px; +} +TD.catName { + width:120px; + padding:0px; + font-weight:bold; +} +td.numeric{ + text-align: right; +} +td.text { + text-align: left; +} +td.normal { + text-align: center; +} +.on{ + color: #080; +} +.off{ + color: #800; +} +TH, td.decor { + text-align:center; + vertical-align:center; + font-size:8pt; + font-weight:bold; + color: black; + background-color: white; + border-bottom: #444 solid 1px; + border-left: #ccc solid 1px; + border-right: #444 solid 1px; + border-top: #ccc solid 1px; +} +TR.data { + text-align:left; + vertical-align:top; + background-color:#222; + padding:4px; + font-size:8pt; +} +TR.data:hover { + background-color:#444; +} +a { + font-weight:normal; + color: #fd0; + text-decoration: none; +} +a:hover { + text-decoration: underline dotted; +} +a::selection { + background-color: #fd0; + color: #000; +} +table { + margin:0px auto; + table-layout: fixed; +} +.bordered { + gap: 0px; + padding: 0px; + border: 1px solid #fff; + border-radius: 0px; +} +caption > div { + display: flex; + align-items: flex-end; +} +.separated { + display: flex; + justify-content: space-between; +} +img.floater { + position: absolute; + /*pointer-events: none;*/ + cursor: pointer; + z-index: 0; + opacity: 0.5; +} +.icon-yes { + width: 12px; + height: 12px; + background-image: url('../octicon/check-circle-fill_12_080.svg'); + background-size: contain; + background-repeat: no-repeat; + opacity: 0.5; +} +.icon-no { + width: 12px; + height: 12px; + background-image: url('../octicon/x-circle-fill_12_800.svg'); + background-size: contain; + background-repeat: no-repeat; + opacity: 0.5; +} +.bar { + position: relative; + width: 100vw; + height: 26px; +} +.centered { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + white-space: nowrap; +} +.centerH { + position: relative; + left: 50%; + top: 0%; + transform: translateX(-50%); + white-space: nowrap; +} +.left { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + white-space: nowrap; + text-align: left; +} +.right { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + white-space: nowrap; + text-align: right; +} +::selection { + background-color: white; + color: #000; +} +.spacer { + display: inline-block; + width: 24px; + height: 24px; +} +[tooltip] { + position: relative; + --tooltip: attr(tooltip); +} +[tooltipr] { + position: relative; + --tooltip: attr(tooltipr); +} +[tooltip]:after, [tooltipr]:after { + content: var(--tooltip); + position: absolute; + top: 200%; + color: #0cc; + background-color: #000; + border: #0cc solid 1px; + width: max-content; + white-space: pre-line; + overflow-wrap: break-word; + padding: 1px; + display: none; + z-index: 9999; +} +[tooltip]:after { + left: 0; +} +[tooltipr]:after { + right: 0; +} +[tooltip]:hover:after, [tooltipr]:hover:after { + display: block; +} +form { + font-size:8pt; + display: inline-flex; + flex-direction: column; + gap: 4px; + padding: 4px; + border: 1px solid #fff; + border-radius: 0px; +} +button { + font-size:8pt; + cursor: pointer; + color: #fd0; + border: 1px solid #fd0; + background-color: #000; +} +input, select { + box-sizing: border-box; + font-size:8pt; + color: #fff; + border: 1px solid #fff; + background-color: #000; +} +:focus { + font-size:8pt; + color: #fff; + border: 1px solid #fff; + background-color: #000; +} +.text-box { + line-height: 2ex; + padding: 4px; + border: 1px solid white; + white-space: normal; + overflow: auto; + overflow-wrap: anywhere; + box-sizing: border-box; +} \ No newline at end of file diff --git a/api/template.html b/api/template.html new file mode 100755 index 00000000..5b7440f8 --- /dev/null +++ b/api/template.html @@ -0,0 +1,60 @@ + + + + outte++ + + + + + + + + + + +
+
+ Party button + +
+ +
+ - outte++ API : TOKENTITLE - +
+ +
+ Home + Source code + + Discord server + Forums + Droni + + N++ on Steam + N++ on SteamDB + N++ Website + Metanet Software +
+
+ +
+ +
+ git · + run · + scores +
+ +

+ +TOKENCONTENT + +  + + + + + + diff --git a/db/71_refactor_userlevel_players.rb b/db/71_refactor_userlevel_players.rb new file mode 100644 index 00000000..0b182c85 --- /dev/null +++ b/db/71_refactor_userlevel_players.rb @@ -0,0 +1,46 @@ +# Merge userlevel players together with players. +# +# Notes: +# They were originally separated for performance related reasons which are no +# longer relevant, plus now that we store both Steam IDs and display names in +# the players table, we need to merge them so that those two features are +# available for userlevels. Nevertheless, we also +class RefactorUserlevelPlayers < ActiveRecord::Migration[5.1] + def change + # Add a field that keeps track of which players have Metanet scores, so that + # we can filter them quickly for Metanet queries if needed. + add_column :players, :metanet, :boolean, index: true, default: false + Player.update_all(metanet: true) + + # We need to temporarily store the metanet ID in the userlevel scores table + # so that we can later update the player IDs, which will change during the + # migration. + add_column :userlevel_scores, :metanet_id, :integer + count = UserlevelPlayer.count + pad = count.to_s.length + i = 1 + + # Migrate userlevel players over to the players table, bar duplicates + UserlevelPlayer.find_each{ |up| + print("Migrating userlevel player #{"%#{pad}d" % i} / #{count}...".ljust(80, ' ') + "\r") + + # Store metanet_id and create new player if it doesn't already exist + UserlevelScore.where(player: up).update_all(metanet_id: up.metanet_id) + p = Player.where(metanet_id: up.metanet_id) + .first_or_create(metanet_id: up.metanet_id, name: up.name) + + # Update player_id fields in tables that reference userlevel_players + if p.id != up.id + UserlevelScore.where(metanet_id: up.metanet_id).update_all(player_id: p.id) + UserlevelHistory.where(metanet_id: up.metanet_id).update_all(player_id: p.id) + end + + i += 1 + } + puts ' ' * 80 + + # metanet_id fields no longer needed + remove_column :userlevel_scores, :metanet_id + remove_column :userlevel_histories, :metanet_id + end +end \ No newline at end of file diff --git a/db/boot.sql b/db/boot.sql new file mode 100644 index 00000000..04eb3f3f --- /dev/null +++ b/db/boot.sql @@ -0,0 +1,58 @@ +/* + * Use this script when inne has been down for a long time (over a day) so + * that the dates are updated correctly. + * + * Usage (for Linux): mysql -uroot -proot inne < db/boot.sql + * Date format: 2020-02-18 21:00:00 +0100 + * + * My (Eddy) old .sqliterc file (SQLite booting file): + * .header on + * .mode column + * .timer on + * .width 2 20 25 + * .open inne.db + */ + +UPDATE global_properties +SET `value` = '2020-11-03 22:00:00 +0200' +WHERE `key` = 'next_score_update'; + +UPDATE global_properties +SET `value` = '2020-11-03 22:00:00 +0200' +WHERE `key` = 'next_level_update'; + +UPDATE global_properties +SET `value` = '2020-11-03 22:00:00 +0200' +WHERE `key` = 'next_episode_update'; + +UPDATE global_properties +SET `value` = '2020-11-03 22:00:00 +0200' +WHERE `key` = 'next_story_update'; + +UPDATE global_properties +SET `value` = '2020-11-03 22:00:00 +0200' +WHERE `key` = 'next_demo_update'; + +UPDATE global_properties +SET `value` = '2020-11-03 22:00:00 +0200' +WHERE `key` = 'next_report_update'; + +UPDATE global_properties +SET `value` = '2020-11-03 22:00:00 +0200' +WHERE `key` = 'next_userlevel_score_update'; + +UPDATE global_properties +SET `value` = '2020-11-03 22:00:00 +0200' +WHERE `key` = 'next_userlevel_report_update'; + +UPDATE global_properties +SET `value` = '2020-11-03 22:00:00 +0200' +WHERE `key` = 'next_history_update'; + +UPDATE global_properties +SET `value` = '2020-11-03 22:00:00 +0200' +WHERE `key` = 'next_userlevel_history_update'; + +UPDATE global_properties +SET `value` = '2020-11-03 22:00:00 +0200' +WHERE `key` = 'next_userlevel_tab_update'; \ No newline at end of file diff --git a/db/challenges/S2codes.txt b/db/challenges/S2codes.txt new file mode 100755 index 00000000..826a7168 --- /dev/null +++ b/db/challenges/S2codes.txt @@ -0,0 +1,600 @@ +NG +NGAT +NGAT +NG AT +ATAC AT AC +NG +NGAT AT +NGATNC AGNTNC +NGAT NGNT +NGAT +NGAT NGNT +NGNT +NG +NG +NGAE NG +NGNT AT +NGNT AGAT +AGAE NGAE +NGNC NG NC +NGAT NC AGNT +AGNT +NGAT AT +AT NG +NG NGNT +AGAE +NG +NGAT AT NG +NG AGNT AT +NG +NGAT +NG +NG +AE NG NGAE +NGAOAC +NG +NGAT NG AT +NGAT +NGAT +NG +NGAT +NGAT +NG AT +NG +NGATAE +AGAT NG +NG +NGAC NG NC AGNC +AE NGAT +NGAT NG AT +AGAE +NGATAOACAE +NGAT +NG +NG +NGNTAE NGAT AGNTAE +NG +NGAT NG AT +NGNT NGAC +NGAT +NGAT +NG +NGNT +NG +NGNT +NGNT +NG +NGAC +NG NGAO +NGAT +AGNT +NGNT AGAT +AGAE +AT +AGAE +NGAC AE ACAE NC +AGNT +NGAC NGNC +NG +NG +NGAT AGAC +NG +NGAT +NG +NG +NG +NG +NG +NGAT NG AT +NGAT AT +AGAT NGNT +AT +NG +NG +NG +NGAC +AGAT +NGAE +NGAT +NG AT +NG AT NGAT +NGAT +NGAT +NGAT +NG +AGNT +NGAT +NGAT +NGNT +NGAT +AGAT +NGAT AGNT +NGAT +NGNT AGAT +AGAT NGNT +NGAE +NG +NG +NG +AGAT +NGAE +AGNT NGAT +NG +NGAE +NG +NG +AT +AGAC +NGNTAC +NGAT +AGAE AE +NGAT +NGAT +AT +AT +NG +NG +AT +NG +AGAC NC +NGAT NG AT NGNT +NG +AT +AGAT +AE NC AC +NGAE NG AE +NG +NGAT NG AT +NG +AGNT +NGAT +NGAT +NGAT NG AT +AGAT AT +NGNT AGAT +NGAT AGNT NGNT +NG +NGATAC AT NGAT +NGAE AGAE +NGNTNONC +NGAE NGNT AGATACAE +AC AE NGAT NGNT NGNTAOACAE +NG +NG +NGAT +NGNT AGAT +NG +NGNTAO NG AT AO NGAT AGNT +NGAT AGNT +NGAT AGNT +NG +NG +NG +NGNTAC NGNT +ATAE AE +NGNT +NGAT NG +NGAT +NG +NGNTAE +NGAT NG AT +NG +NG AC +NGNT NT NG +NGNT +AGNT +AC +NG +NG +NGAT +NGAT +NG +NGAE +AO +AGAE NG +NG +AGAT +NGATAO NG AT +AE +AGAT +NG +NG +NG +NGAT +NGAT AGNT +NG +NGAE +NG +NGNT NGNTAO +NG +AGAE AGAT +NGATNO +NG +NG +NGAE +AC AGAT +NG +AGAT +NG +NG +NG +NGAT NG AT +AGAT AT AC AGAC +NG +NG +NG +NGAT +AGAE AE NG +NG +NGAC AC +AGAT +NGAC +NGNT NG AT AGNT +NGNT +AGNT +NG +NGAC NGAT +NG +AT +NGAE +NGNT NGAT +NG +NGATNONE +AGNT +NGNT +NGAO AGAT +AGNT NT +AGATAO NGNT +NG NC +NGAT +NG +NG +NG +NG AGNT +AT +NG +NG +AGNT +NG +NGAT +NGAT +AC NC +NG +NGAT +AGAT +NGAT +AGAE +NG +AGAO +AGATAE +AGAE AGNE +NT NGAT +NGAT AT NG +AGATACAE +AT NG +NG +NGATAC NG AT AC +NGAT NG +NG AT +NGAT NG AT +NG +NGAE +NGNT NGAT +NGAT +AGNT +NGAC +NG +NGAT +NGAO +NG +NGAT +AT +NGNT AGAT +NGAT +NGAE +NG +NGAT NGNT +NGNTAE AE NGAT +NGAT +AE +AGAT +NG +NG +NG +NGNT +NG +NGAT +NGAT AT +NG AT NGAT +NGNT +NGAT +NG +NG +NG +AGNT +NG NC +AT AC +NG +NG NT AE +NG +AGATAE +NG NC AC +AGAT +AGNTAE AT +AT NT +NGAT +NGAT +NGNTNE AGATAE +NG +NGAT +AT +NG +NGAT +NG +NGAT +NGAT NGNT +NG +AGAC +NGAT +AGATAOAC AT AC +NG +NGAT +NGAT +AT +NG +AE +NG +NGATAC NGAT AC AGNT NGNT NGNTNONC +AGAE +NGAE +NGNTNE AE AT AGATAE AGNT +NGAT +NGNT +NGNT +NG +NG NGNO +NG +NGAT NGNT +NGAT +NG +NGAT +NGAT +AGAT +NGNC +AE +NGNT +NGAT +NGNT +AT +NGAC +AT NGATAOAE +NGAT +NG +NGAC +AGATAO AGATAOAC +NG +NGATAE +NGAT +NG +NG +NGNT +NGNT NG NT +NGNT +NG +NG +AOAC AO AC NGNONC +NG +NGNTAC +AGNT NGAT +NGAT +NGAO NG +NG +NG +NG +NG AT +NGAC NG AC +NGAT +NGNT +NG +AT NGAT +NG +NG +NGNT +NG +NGNTAOAC AGATNONC +AGATAO AGNO +AGAT NGNT +NG +AGAT +NGAO +NGAT +NG +NGAT +NGATAO +AGNT NGAT +NGAT +NGAT +NGNT +NGAT +NG AT AGATAOACAE +NGNTAO +AO +NGAT +AC NGNC +AC AGAT +AT +NG +NT +AGAT +AGAT +NG +NG +NGNT +AC +AC +NGAT +NGAT AGNT +NGAT +NG +NG +NGAT +NG +NG +AGAC +NG +AO NG NGAO +NG +NGNT +NGAT +NGNT AC NGNTAC +NGNT AGAT +NGAC AGAT NT +NG +NGAT +NGAC +NG +NG +AGAT +AGATAE NGNT +NGAE +AGAC +NG +AE NGAE +NG +NGAT +NG +NGNT +NT +NGNTAO +NG +NG +AGNT +AGAT +NG +NGAC +NT +NG +NGAT AT NG +NG +AT +NGAT NGAC +NG +NG +AE NT +AT AO NG +AGAT NGNT +NG +NG +AGAT +NGNT NG NT AGAT +NGAT +NGAT +NG +AGAT +NG AT +AGAT +NGNTAOAC AGATAOAC NGATAOAC AGNTAOAC +AT +NGAT +AGATAOACAE AGATAE +NG AGAT AOAC AGATAOACAE +NGNTAC AGNT +NGAT +AGNT +NGAT +NG +NG +NGAC +NG +NG +NG AGAE +NGAT +NGAT +NGAT +NG +AT AC +NG +AT +NGNT +AGNT +NG NT +AGNT +NGAT +NG +AT +NG AE +NGAT +AT NGAT +NGAOAC +AGAT +NG +AT +AE +NGNT +NG +NGAE +AT NGAT +NGNT +NG +NG +NG +NGAT +NG +NGAT +NGAC +NG +AC +NG +AC +AT AGNT +NG +NGAT +NGAC +AT +NGAT +AGNT +NG AT NGAT +AT +AGAT NGNT +AT +NG +AT +NGAT NG AT +NG +NG +AT NG NGAT +AGAT +NGAT +NG AT +AGAC +NG +AGAC +NG +AT NG +AGATAE +NG +NG +NGAT NG AT +AGAT +NT NG +NGAC AC NG +NGAT NG +NGAT AGNT +AGAT +NG +NG AT NGAT +AE +NGNT +NG +NGNT +NGAT +NG +AT +NG +NG +NG +NGATAE NG NGAT AE AGATAOACAE NGNTNONC +NGAT +NGAT AGNT +NG +AT AC NG AO NGNTNONC AGATAOAC diff --git a/db/challenges/SS2codes.txt b/db/challenges/SS2codes.txt new file mode 100755 index 00000000..ca00f157 --- /dev/null +++ b/db/challenges/SS2codes.txt @@ -0,0 +1,120 @@ +NGAT +AT +AT +NGAT NG AE +AGAT +NGAE AGAE +AGATAE NGNTAE +AT NG NGAT +ATAE NGNT +NG +NGAT AGNT AGAT NGNT +NG +NG AT AC +AT NG +NGAT AGAT +AT NG AE NGATAE +NGAT +NGNTNO NG NT AT +AGAT +NG +NG +AT +AE NG +NG NGAT +AGAT AT +NG AT NGAT +AT +AGAT +NGATAO +NG +AGAT NT +AT NG +NTAE NGAE AT +AGAT +AGAT AGNT +NG AT +NGAT NG +NGNT +AE NGNTAE +AT NG +AO NG +AGAT +AT +AGAT +AGATAOACAE AT AC AO NGAO +AGATAOACAE AGAT +AGATAC AC AT +NG AT NGAT +AGAT +NTAC NT AC NG +AC NGAO AT +NG +AGAT +NGAO AE AOAE +AT NG AC +AGATAOACAE NGNT +NGATAOAC NG AT AC +NGAT +NG +AGAT +AT +NGAT +ACAE +AT +NG AT +NGAC +AGAT AGNT +NGATAE +NG +NG +AT NG AE +AE NG +NG NC +AT +NGAT AGNT +NG +NGAT NG AT +NG +AGNT +NGAT NG AT NT +NG +AGAT AT +NGNT AGAT +AGNT AT +NG AC +NG AT +AGAT +AC +AO +AE +AC AT +AT AE +AT +NG +NG +NG +NGAT NG AT +AE AT +AE NGNE +NGAT NG AT +NGAE NG AE +AGAT +NG +NGNT AGAT +AE NG +NG +NG AT +NG +NG +NG +AGAT +NGAT AE NT +NG +ATAOACAE AT AO AC AE AGNT +NG +NG +AGATAOAC AOAC +AGAT +AE NT +NT diff --git a/db/challenges/SSS2codes.txt b/db/challenges/SSS2codes.txt new file mode 100755 index 00000000..e5135d05 --- /dev/null +++ b/db/challenges/SSS2codes.txt @@ -0,0 +1,25 @@ +NG +NONC AGAOAC AGNO +AT AC NGNTAONC +AGATAOACAE NG +NGAC NG AC AT +NG NT NGNT +AC AE AT +NGNTAOACAE +NG AO +NG +NG NT +AGNT AGNC +NC AO AT +AT NG +NGNT AO AC +NT NG +NG AC +AC NG +NG AC +AT +AT NG NT +AE AC +AT NG AC +NG NC +NGNC NC NGAC NO NGNO \ No newline at end of file diff --git a/db/challenges/SSScodes.txt b/db/challenges/SSScodes.txt new file mode 100755 index 00000000..aaefaf9b --- /dev/null +++ b/db/challenges/SSScodes.txt @@ -0,0 +1,25 @@ +AT +NGAT NGNO +NG +NGNT +NG +NG AT NGAT +NG +NG +AT +NG +NGNTAE NGNT ATAE AGNT NGAT +NG +NG ATAOACAE +NG AT +AT +NGNC NGAC AGNC AGAC +NGNTAONCNE +NG +NGNC NGNTNCNE AGATACAE +NG AGATAO +NG AT +NG AE +NG +NGNC +AGNTNONCNE \ No newline at end of file diff --git a/db/challenges/SScodes.txt b/db/challenges/SScodes.txt new file mode 100755 index 00000000..391ed72a --- /dev/null +++ b/db/challenges/SScodes.txt @@ -0,0 +1,120 @@ +AGNTACAE AT NGNTNCAE AGATACAE +NGAT AT AGATAC NGNTNC AGNT AGATNC +AGAT AT +AGAEAO AE AO +NGAT NGNT AGNT AGAT +NGAT AGATAC AC NG NC NGNC +AE AGAT AGNT NGAT NGNT NCAE +AGAC AC AO AOAC AGAOAC +NGNTNE NGAT AT AGNTAE AGNE AGATAE +AGATAC AT AC NC +AGNT AGAT NGNT NGAT +NG +AE NGAT +NGNTAO NGAT AT AGAT NG AGATAO +NGAT NGNTAO NG AT AGATAOAC AC +NG +NGAT AT NT +NGATAO AC NC AGNTAONC ATAOAC AGATAOAC +NGNTNE NGATNE NGAE AE AGATAE AT +NGAO NGAT ATAO AGATAOAC +NGAT AT NGNT +ATAOAE AT NG NGATAOAE NT NGNT +AE AC NGAT AGATACAE +AGATAOAC AT AO NT +AGATAOAC NGAT AGNT NG NT NC +NGATAOAC AT NGAT AGATAOAC +NGNTNE NG AE NGAE AGNT AGATAE +NGAT AGNT AGAT NGNT AT NT +AGATAOAE AGNO +NGAT NGNTAONCNE NTAE AE AGAOACAE AGATAOACAE +AT AGNT +AGAT AGNT +AGAT NGNT +AC NGNTNO AGATAO AT AGATAOAC +NGNTAE AE NG AT NT AGATAOACAE +AGATAOAE NGATNONE NGATNOAE AGNTAOAE NGNTNOAE NGNTNONE +AT +NGAT AT NT NG NGNT +AT NT +AGATAO NGNTNO AGNT AT +AGAT NT +ATNC ATAC AT NC +AGATAOAE NE NGNT +NG +NGNTAE AT NG AGNE NTNE AGATAE +NG +AGAT AGNT +AC AGATAO NGNTNONC AT NT NG +AE AT AGAT AGATAE +AOAE AGAOAE ATAE NTAE AT NT +AT NGNTAE NGNT AGNTAE AE AGNT +NGAT AT NT NGNT +NGAT NGNTNOAC NGNTAONC AT AGATAOAC AGNC +NGAT AGAT NGNT +AE AGATAOACAE AGNC NGAT NGNTAE NGNTNONCNE +NGAT AT +AGATAC AC AT NG +NGAT AT AGNT NT NGNTAONC +NGNTAOAC AGAT NT AGNT NG AOAC +NGAT AT NG +NGATNC NGNT AGAT AGNT NC +ATAOAE NGNTNOAE AO AT NTAO AGNTNONE +NGNT NG AT AGAT AGNT +NGAT AT +AE NGAT AGNTNONE AGAO +NGAT NG AT +ATNE AGAE NGNE AT +NGNTAE NGAT NC NG AE NGNTNONCNE +AGATAOAC ATAOAC AT AC +NTNONC NGNT AT AGAOAC +NGNT NGAT AGNT AGAT +AT NG +NGAT AGATAOAC NTAONC NGAO AT NT +NGNTAO NGAT +NGAT AGNT AE NGNTNONCNE AGATAOACAE +AT +NGAT AGNT NGNT AGAT +NGNT NGAT AGNT AGAT AT NT +AT NGNT NT NG +NGAT NGNT AGNT AGAT +NGAT AT +AC NG +AGNTAE NGATAE AGATAE NGNTAE +AT NO AGNT +AGATAOACAE NGNTNONC AT AE AC AO +AGNT AT +NGAT NG AT +NGAT NGNTAO AGNTNO AGATAO +NTAOAC AT NG +NGATACNE NCAE AGACAE AC NC AT +NGATNONCNE AGATAOACAE NGNTNONCNE AT AC AE +AGATAE NGNTAOACNE AOACAE NGATAE AGNT AE +NGAOAT +NGATAE AGNT AGATAE AT NG NT +AGATAOAC AT NE AE AO AC +AT NG AC +AT AGNT +AGATAOACAE AE NO NT AT ACNE +NGATAO NGAT AGATAOAC AGNC +NGATAOAC NG AT AOAC +AGATAOACAE NTNOACAE NOACAE NGNTNO NGAT AGNO +NG +NGATAONCNE AC NGNO NGAE AGAONT +NT AT +ATAE NCAE AT NGNTAOACNE NGATNC AGNTNC +AGATAOAE +NGNC +NGNT ATNE AT AE +AE AC NG AT NT AGNT +AGAT AT AGNC +AGATAONCAE AC +AGAT AT AGNC +AOACNE NGNTAE +NG +NGATAOACAE NG AT AE +AGAT +NE AGAOAE AT +NGATNONE AGATAOAE +AGATAC AT AC +AGAT \ No newline at end of file diff --git a/db/challenges/Scodes.txt b/db/challenges/Scodes.txt new file mode 100755 index 00000000..38c9f2eb --- /dev/null +++ b/db/challenges/Scodes.txt @@ -0,0 +1,600 @@ +NG +NGNT +NGAT +NGAT AGAT +NGAT NG AT +AGNT +AGAC +AT +NGNT NG NT NGAT +NGAT AGNT NGNT AGAT +NGNTAC NGAT +AGNT AGAT +NG +AGNT AT +NGAT NG +NG +NGNTAC AGAT +NGAT NG +NG AT +AT +NGNT NGAT AGNT AGAT +NG +NGNTAOAC AC NG AT NGAT +NGNT +NG NGNT NGAE AGNT AT AGATAE +NGAT NG AT +NGAT NG AT +NGAC +AT +NGNTAE NG AGNT NGATAE +NGNT NG +AT NG +NGAT +NGAT NG AT +AGAT AT +NGAT NG +NGNT NG NT +NGAT NGNT +AT NG NGAT +AGATAE NG AE NGAE NGNTNE +NGATAO AGNTNO +NGAT AGNT +NGAT +NT AT +NG NT AGAT +NGAT +NG +AGAT NGNT +NGAT +NGAT NG AT +NGAT AT +NG +ATNG +NGNT NG NGAT +AGAT NGNT +NGAT AT NG +NG NT AGAT NGNT +NGNT AGAT +AGNT AT +NGAT AGNT +NGATNE NGATAE AGATAE +NGNT NG AGAT +NG +NG +NG +NGNTAE AGATNE AGATAE +NGAT AGNT +AT AGNT +AGNTAE NGAT +NGAT NT +NGAT NG AT +NGATNE NGNTAE AGATAE +NGAT NG +NGAT +NGACAE AE AC NG +NG +NGAT +AT NG AGNT +NGAT +NGAT AGNT +NGATNC NG NC +NGAT AGAT NGNT AGNT +NGATNE NGAE NG AGAE AGNT +NGAT +NGAT +AGNT AT NT +NGAT +NGAT +AGATAE NGNTNE +NGAT +NGAT AT NG +AGATAE AT AGNE NGATNE +NGAT AGNT +ATAOAE AGNTNONE +AGNTAE NGATNE +NGAT NG +NGAT +NGAT +NGAT AGNT +NGATAE NG +NGAT AT +NG +NG +NGAT NG +NGAT AT +NG +AGAT +AGNT +AGAT NGNT +NGAT AT +NGAT AGNT AGAT NGNT +NG +NGNT AGAT +AT +NGNTAOAE NGNTAO AE AT NGNO NGATNO +AGNT +NGAT NGNT +NGATAE AT NGAT NGNT AGATAE +NGAT NG AT AGNT +NGATAO AT NGNTNO +NGAT NG +NGAT NG AT +NGNT NG AT AGAT +NGAT NG AT NT AGNT +NGAT NG AT NT AGATAC +NGAT NG +NGAT NG AT NGNT +NGNT AGAT AGNT +AGAT +NG +NGAT AGAT AGNT NGNT +NGAT +AGATNC NT AGATAC +NG +NGAT +NGAT +AGATAE +AGNT AT +NGAT +AT +NG +AGNT AT +NG +NGNT +NGAT AGNT +AT NG +NG +NGNT +NGAOAC NGAONC +AGAT AT NT AGNT +NG +AGAT +NG +NG AGAEAC +NGAT +NG +AGNTAONC AGATAOAC +NGAT NG AT +NG +NG AGNT +NGATACAE NG NT NGAT AGNT AGATACAE +NGAT +AGNT AT +NGAT AGNT NGNT AGAT +NGATAO AGNTNO +NG +NG +NGAT AGNT +AT NGNT +NGATAO +NGATAOAE AT NG +NGAT +NGAT +NGNTAO NG ATAO +NGAT AGNT AGATAO AGNO +AGAT AGNT +NG +NG +NGNT +NGAT +NGNT +AGNT +NGAT +NGAT +AT +NGAT +NG +NGAT NG +NGAT AGNT +NGAT AT +NG +NG +NGATAO AGNTAO AT NT NG +NGNT AGAT +NGAT NGNT +AT +NGATAO AT NGNTAO +NG +NG +NGNTAO AT +NGNT AGAT +NGAT NG AT +NGAT AT +NGAT AGNT +NGAT +NG +NG +NGAT NG AT +AGAT NGNT +NGAT +NGAT NG AT +NGAT AGNT +NGATAO AGNT +NGAT +NGNT +AGNT +NGAT AGAT +NG +NGAT +AGNT +AT +AGNTAO NGATAO +AGNT NGAT +NGAT +NGAT NGNT NG NT +NGAT +NGAT +NGAT +AT NG AGNT +NGATAO +NGAT +NGAT AGNT +NGAT +NGNT NG AT NT NGAT AGNT +AGNT NGAT +AT NG +NG +NGNT +NGAE NG +AGAOAC +NGAT AGNT +NGNTAE AGATAE +NG +AGAT NGNT +NGAT AT NGNT +NG +NGAT +NGAT NT NG +NG +NGNTAO AT NGAT AGNT +NGAT AGNT +NG +NGAT NGNTAC +AO +NG +AGNTNO NGATAO +AT +NGNT AGAT +NG AGAT +AT NT +AGAT +NGAT AGNT +NGNTAO AGNT +AT NGNT +NGAT +NG +NG +AO AGAT +NGNT +AT +AT +NGAT AGNT +NGAT +AGATAOAC NGNT +NGNT NG +NG +NGNT +NGNTNC NGAC NG +NGAT +NGNTAE NGAT +NGAT +NGAT +NG +NG +AGATAE AT AE NGNTAE +NG +NGATAOAC NG AT +NG +NGAT +NGNT AGAT +NGAT AGNT +NG +NT AGNT +NGNT NGAT +NG +AT +NG +NGAT AT +NGAT NGNT +NGAT NG AGNT +NGAT +NGATAE AGNTNE +AT +AGNT +NGATAC +NGAT +NG +NGATAO +NGNT +NG +NGAT NT +NG +NGNT +NGAT NG AT +NGNT AGATAOAC +NGAT +NGNT AGAT +AGAT NGNT +NGATAC AT NT NGAC AC +NGNT NG AT +AGNT AT +AGAT AT +NGAO +NGAT +NG +NGNT AT +AC NGAT AGNT +NG +NGAT +NG +NGAT +NG +NGAT NGNT +NGAT +NGNT +NGAT +NG +ATNC AT +AT +NG +AT AGNT NGNT +NGAT NG AT +AGAT +NGNT +NGNT NGAT AGAT AGNT NT NG +NGAT NG AT +AGATAO +NG +NG AT +NGAT +NG +AGNT AT +NG +AT AGNT +NGAT +AT +NG +NG +NGAT +NG +AGAT +NGNTNC NGNC +NGAT NG AT +NGAT +AGNT +NGAT AT +NG +AT +NG AGAT +NT AT +AT NG AGNT +NGAT NG +AT NGNT +AT NT +NG +NGAT +NG +NGAT +NG +NG +NGAT +NG +NGAT AGNT AT NG +NGNT AT +NGAT +NGAT +NG +AGATAO NG +NG +NGNT AT +NG AT NGAT +NGNT AGAT +NG AT +NGAT NT +NGAT AT NG +NG +NGNT AGAT +NGAT AC +AGNT +NT NG NGAT +NGNT AT +NG +NGATACAE +NG +NGAT AT +NG +NGNT AT +NGATAO NG AT AO +NGNT +NGAT NGNT AT NG +NGAT +NGNT AGAT +NGNT AGAT +ATNO AT AGNT +NGAT NGNT +NGAT NG AT +AGNT NGAT +AGAT +NGNTAO AO NGAT +AGAT AT +NGNT AT +AGAT AGNT +NGAT NG AT +NGNT AGAT +NGAT AT +NGAT AGNT +AGNT +NGAT +NG +NGNTAOAC +NGAT AGNT +NGAT AT AGNT +NGAT AGNT +NGAT +NGAOACAE NGAOAC NGAO AOAC +AGAT AT +AGAT AGNT +AGAOAE +AGATAO AO +AT +NGNT NGAT +AT NG +NGATNC +NGAT NG AT +NGAO AGNO NG NO AT AGATAO +NGAT AT +NGAT +NGAT +AGATAO +NGNT +NG +NGAT AGNT +NG +AT +AGAT +AGATAO +NGNT AGAT +AGAT +NG +NGNT NG AT NGAT +AGNT +NGAT NG +AT +AGAT +NGAT +NGATAONCAE NTAC +NG +NGAC +NGNT AGAT +AGATAOAC AGAT AOAC NG NT NGNTNONC +AT +NGATAOAC NG NGNT AC +NGAT NG +NGAT AGNT +AT NG AO +AGAT +NG +NGNTAE NG AGAT +AT NT +AGNTAC +NGATAOAC +NGNT +NGAT +AT NGNT +NGAT +NGAT +NGNTAO AGATAO +NGAT +NG +NGAT AT NGNT +NG +NGAT NG AT +NGAT +AGAE NE +NG +NGAT NG AT AGNT AGAT NGNT +NGAT NG AT +NGATAOAC NG AT +NGATAC NGATNC AGNTNC +AE NG AT NGAT AGAT +NGAT AT +NGAT +NGATAE AGNTAE AT NT +NGNT AGNT NGAT AGAT +AT +AGAT +NG +NGAT +NGNTAO NGNTNO +AGATAE AT NG NGNT AE NGNTAE +NGATAO +AGATAOACAE NONCAE AT AGNE AE +AGAT +NG +NGAT +NGAT +NGNTAOAC NG NT +AGATAOACAE AGNTAOACNE NGAT ACNE +AGAT +AGNT +AGNT +NGAT AT +AGATAO +NGATAE NGAT AE AGNT +NGAT NG AT +AGNT AT +NG +AGATAOACAE AE AT NGNTAE ATAOAC NGATAE +NGATAO +NGAT AGNT +AGAT NGNT +AGATNC NGAT +AGAT AGNT +NGAT AT AGNT +NGAT +NGAO +NGNT AGAT +AGAT AT NT AGNT NGNT +NG +NGNT AGAT AGNT NGAT +AGAT NGNT AGNT +AT +AT AGNT +NGNT NGAT AGAT AGNT +AGAT AGNT NGAT NGATNC AGATNC NGNTNC +NGAT AGNT +AT +AGATAO +NGNT AGNT NGAT AGAT +NGAT NG AT AGNT +NGAT AGNT +AGNT +NGAT +AT AC +NGAT +AGATAOAE AO NTAO NGAO NGAT NGATAE +NG +NG +NGATAOACAE NGAT AOAC NGNTNONCNE +AT AGAT +NGAT NG AT +AT +AGATAOAE NGNTAOAE +NGAT AGNT +NGAT +NGAT +NG AT +NGAT AGNT +NGAT +NGAT +NGAT +NGAT AT AGNT +AT +NGAT NG AT AE AGATAE +AGNT +AGAT +NGNT AT +AGNT NGAT +AGATAOAE NGNT NGNTNONE +NGAT NG AT +AGAT +NGAT +AGATAOAE AGNTAO NGNTNONE AE AO +NGAT AGNT NG AT NT NGNT +NGNT NG NT AT NGAT AGNT +NGAT NG AT AC AGATAOAC NGAC +NGAT AGNT AGAT NGNT +NGAT NG AT +AOACNE NT AT AE AGATAOACAE +AGAT +AGATAE AT NG NT NGNT NGNTAE +NGAT NG AT +AGAT NG AGNT +NGAT NG +AT AGNC AGATACAE +AGATAE NGAT NGNTAE +AGATAOACAE AE +NGAT +NGATAOACAE AT AO AE AGATAOACAE NGNTAOACNE +AGAT NGNT NGAT AGNT \ No newline at end of file diff --git a/db/config.yml b/db/config.yml index 81d69eed..bbe5ef59 100644 --- a/db/config.yml +++ b/db/config.yml @@ -1,48 +1,20 @@ -inne_eddy: - adapter: sqlite3 - database: inne_eddy.db - host: localhost - token: NTM1NjM0MTMwNDM2NjIwMjk4.DyVEpA.kqjJ7CyyArz60qYC01czT9fPGxE - client_id: 535634130436620298 - #level_update_frequency: 60 - #episode_update_frequency: 60 +outte: + adapter: mysql2 + encoding: utf8mb4 + charset: utf8mb4 + collation: utf8mb4_unicode_ci + database: inne + host: localhost + username: root + password: root + pool: 100 # Connection pool size + timeout: 5000 # Time in milliseconds to obtain a db conn (deprecated?) + checkout_timeout: 60 # Time in seconds to obtain a db connection + wait_timeout: 60 # Time in seconds to close idle connections (MySQL) + idle_timeout: 60 # Time in seconds to close idle connections (Rails pool) + reaping_frequency: 60 # Time in seconds to run the db conn reaper +# reconnect: true -development: - adapter: mysql2 - database: inne_back - host: localhost - username: inne - password: '#69inne#69' - level_update_frequency: 60 - episode_update_frequency: 460 - encoding: utf8mb4 - collation: utf8mb4_unicode_ci - #host: localhost - # username: inne - # password: '#69inne#69' - # encoding: utf8mb4 - # collation: utf8mb4_unicode_ci - token: Mjk2MjE3NTkyODA3NDI0MDAw.C7vBqA.2N95P6C0RMnYkdPobKvao4thJ3g - client_id: 296217592807424000 - # level_update_frequency: 60 - # episode_update_frequency: 460 - -production: - adapter: mysql2 - database: inne_dev - host: localhost - username: inne - password: '#69inne#69' - encoding: utf8mb4 - collation: utf8mb4_unicode_ci - token: Mjk2MjE3NTkyODA3NDI0MDAw.C7vBqA.2N95P6C0RMnYkdPobKvao4thJ3g - client_id: 296217592807424000 - token: Mjg5MTQxNzc2MjA2MjY2MzY5.C6IDyQ.1B1a2x_k7CF4UfaGWvhbGFVqdqM - client_id: 289141776206266369 - -test: - adapter: sqlite3 - database: inne-test.db - host: localhost - level_update_frequency: 1 - episode_update_frequency: 5 +# Note: The reaper will: +# * Recover dead connections (those open but without owning thread) +# * Close idle connections (those idling over idle_timeout seconds) \ No newline at end of file diff --git a/db/mappacks/000_met_1.zip b/db/mappacks/000_met_1.zip new file mode 100644 index 00000000..de7d3f0f Binary files /dev/null and b/db/mappacks/000_met_1.zip differ diff --git a/db/mappacks/000_met_2.zip b/db/mappacks/000_met_2.zip new file mode 100644 index 00000000..222e354a Binary files /dev/null and b/db/mappacks/000_met_2.zip differ diff --git a/db/mappacks/001_ctp_1.zip b/db/mappacks/001_ctp_1.zip new file mode 100644 index 00000000..94e61eaf Binary files /dev/null and b/db/mappacks/001_ctp_1.zip differ diff --git a/db/mappacks/001_ctp_2.zip b/db/mappacks/001_ctp_2.zip new file mode 100644 index 00000000..6d617832 Binary files /dev/null and b/db/mappacks/001_ctp_2.zip differ diff --git a/db/mappacks/002_cla_1.zip b/db/mappacks/002_cla_1.zip new file mode 100644 index 00000000..f0736a3a Binary files /dev/null and b/db/mappacks/002_cla_1.zip differ diff --git a/db/mappacks/003_hax_1.zip b/db/mappacks/003_hax_1.zip new file mode 100644 index 00000000..9f09943a Binary files /dev/null and b/db/mappacks/003_hax_1.zip differ diff --git a/db/mappacks/004_dua_1.zip b/db/mappacks/004_dua_1.zip new file mode 100644 index 00000000..6b272cdc Binary files /dev/null and b/db/mappacks/004_dua_1.zip differ diff --git a/db/mappacks/005_asc_1.zip b/db/mappacks/005_asc_1.zip new file mode 100644 index 00000000..ae67cfad Binary files /dev/null and b/db/mappacks/005_asc_1.zip differ diff --git a/db/mappacks/006_spe_1.zip b/db/mappacks/006_spe_1.zip new file mode 100644 index 00000000..bfe63d90 Binary files /dev/null and b/db/mappacks/006_spe_1.zip differ diff --git a/db/mappacks/007_nov_1.zip b/db/mappacks/007_nov_1.zip new file mode 100644 index 00000000..1c131091 Binary files /dev/null and b/db/mappacks/007_nov_1.zip differ diff --git a/db/mappacks/008_neo_1.zip b/db/mappacks/008_neo_1.zip new file mode 100644 index 00000000..cc710160 Binary files /dev/null and b/db/mappacks/008_neo_1.zip differ diff --git a/db/mappacks/009_alk_1.zip b/db/mappacks/009_alk_1.zip new file mode 100644 index 00000000..56b57ac3 Binary files /dev/null and b/db/mappacks/009_alk_1.zip differ diff --git a/db/mappacks/010_des_1.zip b/db/mappacks/010_des_1.zip new file mode 100644 index 00000000..1b68198d Binary files /dev/null and b/db/mappacks/010_des_1.zip differ diff --git a/db/mappacks/010_des_2.zip b/db/mappacks/010_des_2.zip new file mode 100644 index 00000000..d02b63a9 Binary files /dev/null and b/db/mappacks/010_des_2.zip differ diff --git a/db/mappacks/011_ape_1.zip b/db/mappacks/011_ape_1.zip new file mode 100644 index 00000000..9bd38340 Binary files /dev/null and b/db/mappacks/011_ape_1.zip differ diff --git a/db/mappacks/012_ion_1.zip b/db/mappacks/012_ion_1.zip new file mode 100644 index 00000000..7578bc95 Binary files /dev/null and b/db/mappacks/012_ion_1.zip differ diff --git a/db/mappacks/013_slo_1.zip b/db/mappacks/013_slo_1.zip new file mode 100644 index 00000000..b93e2eea Binary files /dev/null and b/db/mappacks/013_slo_1.zip differ diff --git a/db/mappacks/014_eon_1.zip b/db/mappacks/014_eon_1.zip new file mode 100644 index 00000000..14496ca9 Binary files /dev/null and b/db/mappacks/014_eon_1.zip differ diff --git a/db/mappacks/014_eon_2.zip b/db/mappacks/014_eon_2.zip new file mode 100644 index 00000000..28b9b899 Binary files /dev/null and b/db/mappacks/014_eon_2.zip differ diff --git a/db/mappacks/015_nit_1.zip b/db/mappacks/015_nit_1.zip new file mode 100644 index 00000000..fd305ff0 Binary files /dev/null and b/db/mappacks/015_nit_1.zip differ diff --git a/db/mappacks/016_aci_1.zip b/db/mappacks/016_aci_1.zip new file mode 100644 index 00000000..813ad900 Binary files /dev/null and b/db/mappacks/016_aci_1.zip differ diff --git a/db/mappacks/017_nv1_1.zip b/db/mappacks/017_nv1_1.zip new file mode 100644 index 00000000..006106b0 Binary files /dev/null and b/db/mappacks/017_nv1_1.zip differ diff --git a/db/mappacks/018_max_1.zip b/db/mappacks/018_max_1.zip new file mode 100644 index 00000000..f0e79c01 Binary files /dev/null and b/db/mappacks/018_max_1.zip differ diff --git a/db/mappacks/019_sl2_1.zip b/db/mappacks/019_sl2_1.zip new file mode 100644 index 00000000..63a63f65 Binary files /dev/null and b/db/mappacks/019_sl2_1.zip differ diff --git a/db/mappacks/020_hor_1.zip b/db/mappacks/020_hor_1.zip new file mode 100644 index 00000000..18de11fd Binary files /dev/null and b/db/mappacks/020_hor_1.zip differ diff --git a/db/mappacks/021_lit_1.zip b/db/mappacks/021_lit_1.zip new file mode 100644 index 00000000..d4ccd5b3 Binary files /dev/null and b/db/mappacks/021_lit_1.zip differ diff --git a/db/mappacks/022_ddt_1.zip b/db/mappacks/022_ddt_1.zip new file mode 100644 index 00000000..96610c73 Binary files /dev/null and b/db/mappacks/022_ddt_1.zip differ diff --git a/db/mappacks/023_act_1.zip b/db/mappacks/023_act_1.zip new file mode 100644 index 00000000..c102594e Binary files /dev/null and b/db/mappacks/023_act_1.zip differ diff --git a/db/mappacks/digest.json b/db/mappacks/digest.json new file mode 100644 index 00000000..b4004c9d --- /dev/null +++ b/db/mappacks/digest.json @@ -0,0 +1,1292 @@ +{ + "config": { + "levels_dir": "Levels", + "palettes_dir": "Palettes", + "level_files": [ + "SI.txt", + "S.txt", + "SL.txt", + "SS.txt", + "S2.txt", + "SS2.txt", + "SSS.txt", + "SSS2.txt", + "CI.txt", + "C.txt", + "C2.txt", + "CL.txt", + "CL2.txt", + "CT.txt", + "RI.txt", + "R.txt", + "R2.txt", + "RL.txt", + "RL2.txt", + "RT.txt" + ], + "challenge_files": [ + "Scodes.txt", + "SScodes.txt", + "S2codes.txt", + "SS2codes.txt", + "SSScodes.txt", + "SSS2codes.txt" + ], + "authors_file": "AUTHORS", + "scores_file": "SCORES" + }, + "tabs": [ + { + "attributes": { + "id": 0, + "name": "Metanet", + "code": "met", + "authors": "Metanet Software", + "date": "2015-07-28T00:00:00.000Z", + "version": 2, + "lotd": true, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/000_met_2.zip", + "size": 1837670, + "md5": "552ae458569cc84c5b54304194140fc4" + }, + "disk": { + "size": 12732852, + "level_files": [ + "C.txt", + "C2.txt", + "CI.txt", + "CL.txt", + "CL2.txt", + "CT.txt", + "R.txt", + "R2.txt", + "RI.txt", + "RL.txt", + "RL2.txt", + "RT.txt", + "S.txt", + "S2.txt", + "SI.txt", + "SL.txt", + "SS.txt", + "SS2.txt", + "SSS.txt", + "SSS2.txt" + ], + "challenge_files": [ + "S2codes.txt", + "SS2codes.txt", + "SSS2codes.txt", + "SSScodes.txt", + "SScodes.txt", + "Scodes.txt" + ], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo", + "coop", + "race" + ], + "tabs": [ + "SI", + "S", + "SL", + "?", + "SU", + "!", + "?!", + "CI", + "C", + "CL", + "CT", + "RI", + "R", + "RL", + "RT" + ], + "levels": 4690, + "episodes": 880, + "stories": 65 + }, + "stats": { + "scores": 0, + "recent_scores": 0, + "last_active": null, + "players": 0, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 1, + "name": "Community Tab Project", + "code": "ctp", + "authors": "CTP", + "date": "2023-08-10T00:00:00.000Z", + "version": 2, + "lotd": true, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/001_ctp_2.zip", + "size": 559717, + "md5": "69130c04f330510da95e8c18d27bc04a" + }, + "disk": { + "size": 2620185, + "level_files": [ + "S.txt", + "SI.txt", + "SS.txt" + ], + "challenge_files": [ + "SScodes.txt", + "Scodes.txt" + ], + "palettes": [], + "authors_file": true, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI", + "S", + "?" + ], + "levels": 845, + "episodes": 145, + "stories": 25 + }, + "stats": { + "scores": 62541, + "recent_scores": 177, + "last_active": "2026-08-03T03:38:37Z", + "players": 104, + "recent_players": 4 + } + }, + { + "attributes": { + "id": 2, + "name": "Classic", + "code": "cla", + "authors": "NateyPooPoo", + "date": "2023-05-11T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/002_cla_1.zip", + "size": 61684, + "md5": "1765adbc41c309925eb3c372ca6d49de" + }, + "disk": { + "size": 421669, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [ + "classic darkmode", + "classic lightmode" + ], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 125, + "episodes": 25, + "stories": 5 + }, + "stats": { + "scores": 4872, + "recent_scores": 0, + "last_active": "2026-06-08T17:49:28Z", + "players": 42, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 3, + "name": "HaxPack", + "code": "hax", + "authors": "Ham, l_d, RD, hooz", + "date": "2021-05-29T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/003_hax_1.zip", + "size": 67838, + "md5": "8cce82fef9c5ca0c37802c6545f5dbf7" + }, + "disk": { + "size": 329149, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 125, + "episodes": 25, + "stories": 5 + }, + "stats": { + "scores": 1509, + "recent_scores": 2, + "last_active": "2026-07-15T11:14:32Z", + "players": 19, + "recent_players": 1 + } + }, + { + "attributes": { + "id": 4, + "name": "Duality", + "code": "dua", + "authors": "w95559w", + "date": "2023-05-24T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/004_dua_1.zip", + "size": 53498, + "md5": "0dcc0dcf5fa1f1c0848a3f6055402e87" + }, + "disk": { + "size": 258218, + "level_files": [ + "C.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "coop" + ], + "tabs": [ + "C" + ], + "levels": 80, + "episodes": 16, + "stories": 0 + }, + "stats": { + "scores": 1649, + "recent_scores": 0, + "last_active": "2026-06-06T02:11:47Z", + "players": 16, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 5, + "name": "Ascend", + "code": "asc", + "authors": "kkstrong", + "date": "2023-11-29T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/005_asc_1.zip", + "size": 85014, + "md5": "2b53c019839448072da5885378a3455d" + }, + "disk": { + "size": 441162, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [ + "ascend summit", + "ascend zenith" + ], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 125, + "episodes": 25, + "stories": 5 + }, + "stats": { + "scores": 2613, + "recent_scores": 0, + "last_active": "2025-08-02T21:32:28Z", + "players": 22, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 6, + "name": "Spectra", + "code": "spe", + "authors": "abho & MegalordX8", + "date": "2024-01-03T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/006_spe_1.zip", + "size": 89563, + "md5": "22ba71372b2e720dffeff37e526e9363" + }, + "disk": { + "size": 470039, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [ + "cyberware", + "ultraviolet" + ], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 125, + "episodes": 25, + "stories": 5 + }, + "stats": { + "scores": 2082, + "recent_scores": 0, + "last_active": "2026-06-08T17:33:03Z", + "players": 22, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 7, + "name": "Nova", + "code": "nov", + "authors": "abho & NateyPooPoo", + "date": "2023-03-03T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/007_nov_1.zip", + "size": 110869, + "md5": "b04dc00cf2967db55d4ff59d3afb5e15" + }, + "disk": { + "size": 505250, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [ + "nova cosmic", + "nova orbit" + ], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 125, + "episodes": 25, + "stories": 5 + }, + "stats": { + "scores": 656, + "recent_scores": 0, + "last_active": "2025-03-17T11:50:23Z", + "players": 6, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 8, + "name": "NeoMeta", + "code": "neo", + "authors": "abho", + "date": "2021-11-24T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/008_neo_1.zip", + "size": 52220, + "md5": "dd2e118f4bb0113f90b85fd984b609f6" + }, + "disk": { + "size": 283138, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 100, + "episodes": 20, + "stories": 4 + }, + "stats": { + "scores": 935, + "recent_scores": 0, + "last_active": "2025-09-08T16:06:41Z", + "players": 7, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 9, + "name": "Alkaline", + "code": "alk", + "authors": "MegalordX8", + "date": "2024-05-30T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/009_alk_1.zip", + "size": 149930, + "md5": "6e500f9b36500974a9bfac8740f8b1c5" + }, + "disk": { + "size": 812949, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [ + "parametric-blue", + "parametric-cyan", + "parametric-green", + "parametric-orange", + "parametric-purple", + "parametric-red", + "parametric-rose", + "parametric-tan", + "parametric-teal" + ], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 125, + "episodes": 25, + "stories": 5 + }, + "stats": { + "scores": 3145, + "recent_scores": 0, + "last_active": "2025-03-02T07:09:07Z", + "players": 26, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 10, + "name": "Destab", + "code": "des", + "authors": "dest1ny", + "date": "2024-07-08T00:00:00.000Z", + "version": 2, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/010_des_2.zip", + "size": 331277, + "md5": "d05ec464f6b35ee792850e6076c2a315" + }, + "disk": { + "size": 1185285, + "level_files": [ + "S.txt" + ], + "challenge_files": [ + "Scodes.txt" + ], + "palettes": [ + "aced", + "aroma" + ], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "S" + ], + "levels": 250, + "episodes": 50, + "stories": 10 + }, + "stats": { + "scores": 6234, + "recent_scores": 0, + "last_active": "2026-07-11T00:54:18Z", + "players": 23, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 11, + "name": "Apex", + "code": "ape", + "authors": "abho", + "date": "2024-08-05T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/011_ape_1.zip", + "size": 80791, + "md5": "2ee1d74979bd4d03a7041c39665ada19" + }, + "disk": { + "size": 406356, + "level_files": [ + "S.txt", + "SI.txt" + ], + "challenge_files": [ + "Scodes.txt" + ], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI", + "S" + ], + "levels": 140, + "episodes": 28, + "stories": 6 + }, + "stats": { + "scores": 2610, + "recent_scores": 0, + "last_active": "2026-07-11T00:43:25Z", + "players": 21, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 12, + "name": "Ion", + "code": "ion", + "authors": "NateyPooPoo", + "date": "2023-03-01T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/012_ion_1.zip", + "size": 148654, + "md5": "8413c37b6543ab0645f39922bcf4b5b7" + }, + "disk": { + "size": 713019, + "level_files": [ + "S.txt", + "SI.txt" + ], + "challenge_files": [], + "palettes": [ + "ion engine", + "ion forest", + "ion redshift" + ], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI", + "S" + ], + "levels": 175, + "episodes": 35, + "stories": 7 + }, + "stats": { + "scores": 1574, + "recent_scores": 0, + "last_active": "2025-05-09T02:18:33Z", + "players": 10, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 13, + "name": "Slomac MEGA Pack", + "code": "slo", + "authors": "Slomac", + "date": "2024-09-20T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/013_slo_1.zip", + "size": 268428, + "md5": "66dd978f8ac385f7fbea690113390d9c" + }, + "disk": { + "size": 1328779, + "level_files": [ + "S.txt", + "SI.txt", + "SL.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI", + "S", + "SL" + ], + "levels": 375, + "episodes": 75, + "stories": 15 + }, + "stats": { + "scores": 5287, + "recent_scores": 0, + "last_active": "2026-06-08T21:23:09Z", + "players": 21, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 14, + "name": "Eon", + "code": "eon", + "authors": "NateyPooPoo", + "date": "2024-11-30T00:00:00.000Z", + "version": 2, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/014_eon_2.zip", + "size": 506824, + "md5": "2d65498051c2ec660f6377dd7eabf78f" + }, + "disk": { + "size": 2830112, + "level_files": [ + "S.txt" + ], + "challenge_files": [ + "Scodes.txt" + ], + "palettes": [ + "eon bloom", + "eon blue", + "eon charcoal", + "eon data", + "eon ember", + "eon golden hour", + "eon gravity", + "eon hallway", + "eon hypercube", + "eon leaf", + "eon lightfield", + "eon neon", + "eon ocean", + "eon radius", + "eon red", + "eon" + ], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "S" + ], + "levels": 600, + "episodes": 120, + "stories": 20 + }, + "stats": { + "scores": 17975, + "recent_scores": 7, + "last_active": "2026-07-30T03:40:54Z", + "players": 42, + "recent_players": 2 + } + }, + { + "attributes": { + "id": 15, + "name": "Nitro", + "code": "nit", + "authors": "abho", + "date": "2022-03-26T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/015_nit_1.zip", + "size": 76839, + "md5": "1c53e944623b5077d7db46595cb2f18c" + }, + "disk": { + "size": 414130, + "level_files": [ + "S.txt", + "SI.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI", + "S" + ], + "levels": 140, + "episodes": 28, + "stories": 6 + }, + "stats": { + "scores": 1246, + "recent_scores": 6, + "last_active": "2026-07-31T09:44:25Z", + "players": 12, + "recent_players": 1 + } + }, + { + "attributes": { + "id": 16, + "name": "Acidic", + "code": "aci", + "authors": "MegalordX8", + "date": "2025-02-24T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/016_aci_1.zip", + "size": 64588, + "md5": "8cf544f1b1f5fceba5c23d1a19f7a7d6" + }, + "disk": { + "size": 347157, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 125, + "episodes": 25, + "stories": 5 + }, + "stats": { + "scores": 1710, + "recent_scores": 0, + "last_active": "2026-06-08T22:20:04Z", + "players": 15, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 17, + "name": "Nv1++", + "code": "nv1", + "authors": "Metanet Software", + "date": "2005-05-15T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/017_nv1_1.zip", + "size": 165186, + "md5": "ad1cd93848dc0c6d192573c073c8c071" + }, + "disk": { + "size": 1200701, + "level_files": [ + "SL.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SL" + ], + "levels": 500, + "episodes": 100, + "stories": 20 + }, + "stats": { + "scores": 7903, + "recent_scores": 0, + "last_active": "2026-06-11T22:05:26Z", + "players": 28, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 18, + "name": "Max", + "code": "max", + "authors": "Natey & Slomac et al", + "date": "2025-06-19T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": true + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/018_max_1.zip", + "size": 54559, + "md5": "a2b6b86bfc492794fae9e3bcb9be2b93" + }, + "disk": { + "size": 348046, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": true, + "scores_file": true + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 125, + "episodes": 25, + "stories": 5 + }, + "stats": { + "scores": 4652, + "recent_scores": 0, + "last_active": "2026-06-04T21:08:31Z", + "players": 37, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 19, + "name": "Legacy 2", + "code": "sl2", + "authors": "Metanet Software", + "date": "2015-07-28T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/019_sl2_1.zip", + "size": 62778, + "md5": "91890006764965c563ccb012aae3dc5f" + }, + "disk": { + "size": 566097, + "level_files": [ + "SL.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SL" + ], + "levels": 240, + "episodes": 48, + "stories": 10 + }, + "stats": { + "scores": 3518, + "recent_scores": 0, + "last_active": "2026-06-08T05:48:40Z", + "players": 15, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 20, + "name": "Horizons", + "code": "hor", + "authors": "HamSandwich", + "date": "2025-08-14T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/020_hor_1.zip", + "size": 76096, + "md5": "6434ce9d841d0d8843c7a5df1a8c8458" + }, + "disk": { + "size": 335809, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 125, + "episodes": 25, + "stories": 5 + }, + "stats": { + "scores": 2275, + "recent_scores": 0, + "last_active": "2026-06-06T15:28:03Z", + "players": 18, + "recent_players": 0 + } + }, + { + "attributes": { + "id": 21, + "name": "Little Hops", + "code": "lit", + "authors": "LocoLynx", + "date": "2026-04-15T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/021_lit_1.zip", + "size": 37415, + "md5": "c684d7f09cae02fe9b9f79d7aa727a8b" + }, + "disk": { + "size": 303809, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 125, + "episodes": 25, + "stories": 5 + }, + "stats": { + "scores": 1368, + "recent_scores": 1, + "last_active": "2026-07-21T17:55:38Z", + "players": 12, + "recent_players": 1 + } + }, + { + "attributes": { + "id": 22, + "name": "D-Day Tab", + "code": "ddt", + "authors": "N++ Community", + "date": "2026-05-14T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/022_ddt_1.zip", + "size": 653137, + "md5": "62905da822839afa453651659e219651" + }, + "disk": { + "size": 4378987, + "level_files": [ + "S.txt", + "S2.txt", + "SL.txt", + "SS.txt" + ], + "challenge_files": [ + "S2codes.txt", + "Scodes.txt" + ], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "S", + "SL", + "?", + "SU" + ], + "levels": 1635, + "episodes": 303, + "stories": 58 + }, + "stats": { + "scores": 14834, + "recent_scores": 711, + "last_active": "2026-08-03T05:11:46Z", + "players": 13, + "recent_players": 7 + } + }, + { + "attributes": { + "id": 23, + "name": "Action", + "code": "act", + "authors": "Filip✝", + "date": "2026-08-11T00:00:00.000Z", + "version": 1, + "lotd": false, + "enabled": true, + "public": true, + "fractional": false + }, + "download": { + "link": "https://github.com/edelkas/inne/raw/master/db/mappacks/023_act_1.zip", + "size": 77805, + "md5": "746bebf404466cf04d9faff365fcda48" + }, + "disk": { + "size": 390447, + "level_files": [ + "SI.txt" + ], + "challenge_files": [], + "palettes": [], + "authors_file": false, + "scores_file": false + }, + "properties": { + "modes": [ + "solo" + ], + "tabs": [ + "SI" + ], + "levels": 125, + "episodes": 25, + "stories": 5 + }, + "stats": { + "scores": 0, + "recent_scores": 0, + "last_active": null, + "players": 0, + "recent_players": 0 + } + } + ], + "signature": { + "md5": "42d75a399ec6eb6c8889487eb3b51378", + "date": "2026-08-11T21:35:46Z" + } +} \ No newline at end of file diff --git a/db/migrate/11_create_next_score_update.rb b/db/migrate/11_create_next_score_update.rb index c1c4a2f7..23488dc8 100644 --- a/db/migrate/11_create_next_score_update.rb +++ b/db/migrate/11_create_next_score_update.rb @@ -17,6 +17,6 @@ def change # this is pretty greasy now = Time.now next_score_update = DateTime.new(now.year, now.month, now.day + 1, 0, 0, 0, now.zone) - GlobalProperty.create(key: 'next_score_update', value: next_score_update.to_s) + GlobalProperty.find_or_create_by(key: 'next_score_update').update(value: next_score_update.to_s) end end diff --git a/db/migrate/12_add_ultimate_and_secrets.rb b/db/migrate/12_add_ultimate_and_secrets.rb index d51afcec..fa97e346 100644 --- a/db/migrate/12_add_ultimate_and_secrets.rb +++ b/db/migrate/12_add_ultimate_and_secrets.rb @@ -49,7 +49,9 @@ def change ss = ["?"].product((0..23).to_a).product(["A", "B", "C", "D", "E"]).map(&:flatten).map { |l| shortname(l[0], l[2], l[1]) } ss2 = ["!"].product((0..23).to_a).product(["A", "B", "C", "D", "E"]).map(&:flatten).map { |l| shortname(l[0], l[2], l[1]) } - attrs = get_attributes('names-SU.txt', ue, 2400) + get_attributes('names-SS.txt', ss, 1800) + get_attributes('names-SS2.txt', ss2, 3000) + attrs = get_attributes('db/names/names-SU.txt', ue, 2400) + + get_attributes('db/names/names-SS.txt', ss, 1800) + + get_attributes('db/names/names-SS2.txt', ss2, 3000) Level.create(attrs) diff --git a/db/migrate/14_create_rank_histories.rb b/db/migrate/14_create_rank_histories.rb index 45ec52fe..b80990d3 100644 --- a/db/migrate/14_create_rank_histories.rb +++ b/db/migrate/14_create_rank_histories.rb @@ -9,6 +9,7 @@ def change t.boolean :ties t.references :player + t.integer :metanet_id t.integer :count end @@ -19,6 +20,7 @@ def change t.string :highscoreable_type, index: true t.references :player + t.integer :metanet_id t.integer :points end @@ -29,6 +31,7 @@ def change t.string :highscoreable_type, index: true t.references :player + t.integer :metanet_id t.float :score end end diff --git a/db/migrate/17_create_videos.rb b/db/migrate/17_create_videos.rb deleted file mode 100644 index 7d077d92..00000000 --- a/db/migrate/17_create_videos.rb +++ /dev/null @@ -1,106 +0,0 @@ -require 'csv' - -def user(tag) - { - "ABA" => "ababab777", - "AWE" => "Awein", - "CAM" => "ibcamwhobu", - "CHB" => "Chebyshevrolet", - "EKI" => "ekisakic", - "ES2" => "Alpha Espy's brother", - "ESP" => "Alpha Espy", - "FNY" => "Fnyt", - "GEA" => "Geal", - "GOL" => "golfkid", - "HGB" => "HorribleGBlob", - "HIT" => "Hitfreezy", - "KOS" => "Kostucha", - "MAE" => "Maelstrom", - "MAQ" => "Maqrkk", - "MOL" => "MoleTrooper", - "NIM" => "Untamed Nim", - "PBR" => "poober", - "SCO" => "scottm", - "SKY" => "Skylighter", - "SUN" => "sunruibt", - "SYS" => "systeminspired", - "TST" => "Toaster", - "WHA" => "Whamboss", - "XEL" => "xela" - }[tag] -end - -def add_videos(tab, max_count) - lines = CSV.read(tab + ".csv").drop(5) - - current_name = nil - current_id = nil - challenge = nil - challenge_code = nil - author = nil - url = nil - - lines.each do |line| - if line[1] =~ /[A-Z?!]-[A-Z]-[0-9][0-9](-[0-9][0-9])?/ - current_id = line[1] - challenge = "G++" - elsif line[1] =~ /([GTOCE][-+]+)+/ - challenge = line[1] - challenge_code = line[4] - challenge_code = challenge_code[1..challenge_code.length - 2] unless challenge_code.nil? - challenge_code = "?!" if challenge_code.nil? - else - current_name = line[1] - current_id = nil - challenge = nil - challenge_code = nil - author = nil - url = nil - next - end - - (5..(5 + max_count - 1)).each do |i| - author_tag = line[i] - next if author_tag.nil? || author_tag.empty? - author_tag = author_tag[1..3] - author = user(author_tag) - url = line[i + max_count] - - highscoreable = Level.where(longname: current_name).first - - if highscoreable.nil? - highscoreable = Episode.where(name: current_id).first - end - - highscoreable.videos.create( - author: author, - author_tag: author_tag, - challenge: challenge, - challenge_code: challenge_code, - url: url - ) - end - end -end - -class CreateVideos < ActiveRecord::Migration[5.1] - def change - create_table :videos do |t| - t.belongs_to :highscoreable, polymorphic: true - t.string :author - t.string :author_tag - t.string :challenge - t.string :challenge_code - t.string :url - end - - add_videos("L-S", 10) - add_videos("L-SI", 8) - add_videos("L-SU", 8) - add_videos("L-SL", 8) - add_videos("L-SS", 8) - add_videos("L-SS2", 8) - add_videos("E-S", 8) - add_videos("E-SU", 8) - end -end diff --git a/db/migrate/18_create_userlevels.rb b/db/migrate/18_create_userlevels.rb new file mode 100644 index 00000000..c416f93a --- /dev/null +++ b/db/migrate/18_create_userlevels.rb @@ -0,0 +1,21 @@ +class CreateUserlevels < ActiveRecord::Migration[5.1] + def change + create_table :userlevels do |t| + t.integer :author_id, index: true + t.string :author + t.string :title + t.integer :favs + t.string :date + t.integer :mode + end + + create_table :userlevel_data do |t| + # We limit object data to 1MB to force MySQL to create a MEDIUMBLOB, + # which can hold up to 16MB, otherwise a BLOB is created, which can only + # hold 64KB and is thus not sufficient for the theoretical biggest + # possible maps. + t.binary :tile_data + t.binary :object_data, limit: 1024 ** 2 + end + end +end diff --git a/db/migrate/19_create_stories.rb b/db/migrate/19_create_stories.rb new file mode 100644 index 00000000..6584434e --- /dev/null +++ b/db/migrate/19_create_stories.rb @@ -0,0 +1,42 @@ +# Internally we use the same tabs as Solo mode because, in essence, they're the same +# However, it makes no sense to use the same names, since the evo +def tab(prefix) + { + 'SI' => :SI, + 'S' => :S, + 'SL' => :SL, + 'SU' => :SU + }[prefix] +end + +def ids(tab, offset, n) + ret = (0..n - 1).to_a.map{ |s| + tab + "-" + s.to_s.rjust(2,"0") + }.each_with_index.map{ |l, i| [offset + i, l] }.to_h +end + +class CreateStories < ActiveRecord::Migration[5.1] + def change + create_table :stories do |t| + t.string :name + t.boolean :completed + t.integer :tab, index: true + end + + # Seed stories + ActiveRecord::Base.transaction do + [['SI', 0, 5], ['S', 24, 20], ['SL', 48, 20], ['SU', 96, 20]].each{ |s| + ids(s[0],s[1],s[2]).each{ |story| + Story.find_or_create_by(id: story[0]).update( + #completed: false, # commented because we use nil instead + name: story[1], + tab: tab(story[1].split('-')[0]) + ) + } + } + GlobalProperty.find_or_create_by(key: 'next_story_update').update(value: (Time.now + 86400).to_s) + GlobalProperty.find_or_create_by(key: 'current_story').update(value: 'S-15') + GlobalProperty.find_or_create_by(key: 'saved_story_scores').update(value: []) + end + end +end diff --git a/db/migrate/20_add_ids_to_players_and_scores.rb b/db/migrate/20_add_ids_to_players_and_scores.rb new file mode 100644 index 00000000..403610e2 --- /dev/null +++ b/db/migrate/20_add_ids_to_players_and_scores.rb @@ -0,0 +1,10 @@ +class AddIdsToPlayersAndScores < ActiveRecord::Migration[5.1] + def change + change_table :players do |t| + t.integer :metanet_id + end + change_table :scores do |t| + t.integer :replay_id + end + end +end diff --git a/db/migrate/21_change_users.rb b/db/migrate/21_change_users.rb new file mode 100644 index 00000000..a330a3e7 --- /dev/null +++ b/db/migrate/21_change_users.rb @@ -0,0 +1,18 @@ +# WARNING: This migration is not reversible, use with care! + +class ChangeUsers < ActiveRecord::Migration[5.1] + # From now on, Players will only be created by the bot itself, so + # users will reference player names instead of player ids. + def change + change_table :users do |t| + t.string :playername + end + User.all.each{ |u| + u.playername = (Player.find(u.player_id).name rescue nil) + u.save + } + change_table :users do |t| + t.remove :player_id + end + end +end diff --git a/db/migrate/22_remove_orphaned_players.rb b/db/migrate/22_remove_orphaned_players.rb new file mode 100644 index 00000000..e20dbb55 --- /dev/null +++ b/db/migrate/22_remove_orphaned_players.rb @@ -0,0 +1,22 @@ +class RemoveOrphanedPlayers < ActiveRecord::Migration[5.1] + # We remove every player that does not have a non-null Metanet ID field. + # This means that the player has never had a highscore since tracking began: + # it is orphaned (ie. never used) and is therefore useless. + # + # We also remove orphaned scores, ie., those belonging to players we + # just removed. + def change + players = Player.where(metanet_id: nil) + count = players.size + players.each_with_index{ |p, i| + print("Removing player #{i} of #{count}...".ljust(80, " ") + "\r") + p.destroy + } + scores = Score.where(replay_id: nil) + count = scores.size + scores.each_with_index{ |s, i| + print("Removing score #{i} of #{count}...".ljust(80, " ") + "\r") + s.destroy + } + end +end diff --git a/db/migrate/23_create_archives.rb b/db/migrate/23_create_archives.rb new file mode 100644 index 00000000..fdc9d4f7 --- /dev/null +++ b/db/migrate/23_create_archives.rb @@ -0,0 +1,30 @@ +class CreateArchives < ActiveRecord::Migration[5.1] + + def change + # This will be a differential table holding all the current and new scores. + create_table :archives do |t| + t.integer :replay_id # ID of score in Metanet's db + t.integer :metanet_id, index: true # ID of player in Metanet's db + t.references :player, index: true + t.references :highscoreable, polymorphic: true, index: true + t.integer :score + t.timestamp :date + end + + # This will store all the replay demos of the scores stored in 'archives', + # sharing the same ID. + create_table :demos do |t| + t.integer :replay_id + t.integer :htype, limit: 1 + t.binary :demo + t.boolean :expired + end + # Make sure "htype" is a tinyint(4) in MySQL, because tinyint(1) are + # interpreted as booleans by default and we don't want to override that. + + # Demos will be downloaded in parallel, we give them their own timer. + ActiveRecord::Base.transaction do + GlobalProperty.find_or_create_by(key: 'next_demo_update').update(value: (Time.now + 86400).to_s) + end + end +end diff --git a/db/migrate/24_create_reports.rb b/db/migrate/24_create_reports.rb new file mode 100644 index 00000000..ca469384 --- /dev/null +++ b/db/migrate/24_create_reports.rb @@ -0,0 +1,5 @@ +class CreateReports < ActiveRecord::Migration[5.1] + def change + GlobalProperty.find_or_create_by(key: 'next_report_update').update(value: (Time.now + 86400).to_s) + end +end diff --git a/db/migrate/25_create_userlevel_scores_and_players.rb b/db/migrate/25_create_userlevel_scores_and_players.rb new file mode 100644 index 00000000..c266ef81 --- /dev/null +++ b/db/migrate/25_create_userlevel_scores_and_players.rb @@ -0,0 +1,20 @@ +class CreateUserlevelScoresAndPlayers < ActiveRecord::Migration[5.1] + def change + create_table :userlevel_scores do |t| + t.integer :userlevel_id, index: true + t.integer :score + t.integer :player_id, index: true + t.integer :rank, limit: 1, index: true + t.integer :tied_rank, limit: 1, index: true + t.integer :replay_id + end + + create_table :userlevel_players do |t| + t.string :name + t.integer :metanet_id + end + + GlobalProperty.find_or_create_by(key: 'next_userlevel_score_update').update(value: (Time.now + 86400).to_s) + GlobalProperty.find_or_create_by(key: 'next_userlevel_report_update').update(value: (Time.now + 86400).to_s) + end +end diff --git a/db/migrate/26_fill_htypes.rb b/db/migrate/26_fill_htypes.rb new file mode 100644 index 00000000..75dde28a --- /dev/null +++ b/db/migrate/26_fill_htypes.rb @@ -0,0 +1,13 @@ +class FillHtypes < ActiveRecord::Migration[5.1] + def change + ActiveRecord::Base.transaction do + count = Demo.all.size + Demo.all.each_with_index{ |d, i| + print("Updating demo #{i} / #{count}...".ljust(80, " ") + "\r") + if d.htype.nil? + d.update(htype: Demo.htypes[d.score.highscoreable_type.to_s.downcase]) + end + } + end + end +end diff --git a/db/migrate/27_add_rankings_update.rb b/db/migrate/27_add_rankings_update.rb new file mode 100644 index 00000000..9ceac2cb --- /dev/null +++ b/db/migrate/27_add_rankings_update.rb @@ -0,0 +1,5 @@ +class AddRankingsUpdate < ActiveRecord::Migration[5.1] + def change + GlobalProperty.find_or_create_by(key: 'next_history_update').update(value: (Time.now + 86400).to_s) + end +end diff --git a/db/migrate/28_add_userlevel_histories.rb b/db/migrate/28_add_userlevel_histories.rb new file mode 100644 index 00000000..07d1c754 --- /dev/null +++ b/db/migrate/28_add_userlevel_histories.rb @@ -0,0 +1,14 @@ +class AddUserlevelHistories < ActiveRecord::Migration[5.1] + def change + create_table :userlevel_histories do |t| + t.timestamp :timestamp, index: true + t.integer :rank, index: true + t.references :player + t.integer :metanet_id + t.integer :count + end + + GlobalProperty.find_or_create_by(key: 'next_userlevel_history_update').update(value: (Time.now + 86400).to_s) + end +end + diff --git a/db/migrate/29_add_score_indexes.rb b/db/migrate/29_add_score_indexes.rb new file mode 100644 index 00000000..80f25679 --- /dev/null +++ b/db/migrate/29_add_score_indexes.rb @@ -0,0 +1,19 @@ +class AddScoreIndexes < ActiveRecord::Migration[5.1] + def change + # Add the tab to the scores as well. This is redundant, but it makes + # queries significantly simpler and more efficient compared to having + # to query the corresponding highscoreables every time. + add_column :scores, :tab, :integer, limit: 1 + total = Score.all.size + Score.all.each_with_index{ |s, i| + print("Updating tab for score #{i} / #{total}...".ljust(80, " ") + "\r") + s.update(tab: s.highscoreable.tab) + } + + # Add extra indexes to relevant columns to further enhance performance. + add_index :scores, :tab + add_index :scores, :tied_rank + add_index :scores, :highscoreable_type + add_index :scores, :highscoreable_id + end +end diff --git a/db/migrate/30_add_archive_indexes.rb b/db/migrate/30_add_archive_indexes.rb new file mode 100644 index 00000000..b7dbc077 --- /dev/null +++ b/db/migrate/30_add_archive_indexes.rb @@ -0,0 +1,17 @@ +class AddArchiveIndexes < ActiveRecord::Migration[5.1] + def change + # Add the tab to the archives as well. This is redundant, but it makes + # queries significantly simpler and more efficient compared to having + # to query the corresponding highscoreables every time. + add_column :archives, :tab, :integer, limit: 1 + total = Archive.all.size + Archive.all.each_with_index{ |s, i| + print("Updating tab for archive #{i} / #{total}...".ljust(80, " ") + "\r") + s.update(tab: s.highscoreable.tab) + } + + # Add extra indexes to relevant columns to further enhance performance. + add_index :archives, :tab + add_index :archives, :date + end +end diff --git a/db/migrate/31_add_levels_to_episodes.rb b/db/migrate/31_add_levels_to_episodes.rb new file mode 100644 index 00000000..bb0c651d --- /dev/null +++ b/db/migrate/31_add_levels_to_episodes.rb @@ -0,0 +1,16 @@ +class AddLevelsToEpisodes < ActiveRecord::Migration[5.1] + def change + change_table :levels do |t| + t.references :episode + end + + ActiveRecord::Base.transaction do + Episode.all.each{ |e| + print("Adding levels to episode #{e.name}...".ljust(80, " ") + "\r") + Level.where("UPPER(name) LIKE ?", e.name.upcase + '%').each{ |l| + l.update(episode: e) + } + } + end + end +end diff --git a/db/migrate/32_add_last_userlevel_update.rb b/db/migrate/32_add_last_userlevel_update.rb new file mode 100644 index 00000000..d2ecd24b --- /dev/null +++ b/db/migrate/32_add_last_userlevel_update.rb @@ -0,0 +1,5 @@ +class AddLastUserlevelUpdate < ActiveRecord::Migration[5.1] + def change + add_column :userlevels, :last_update, :timestamp + end +end diff --git a/db/migrate/33_add_display_names.rb b/db/migrate/33_add_display_names.rb new file mode 100644 index 00000000..354585bd --- /dev/null +++ b/db/migrate/33_add_display_names.rb @@ -0,0 +1,5 @@ +class AddDisplayNames < ActiveRecord::Migration[5.1] + def change + add_column :users, :displayname, :string + end +end diff --git a/db/migrate/34_add_display_names_to_players.rb b/db/migrate/34_add_display_names_to_players.rb new file mode 100644 index 00000000..647a21f7 --- /dev/null +++ b/db/migrate/34_add_display_names_to_players.rb @@ -0,0 +1,5 @@ +class AddDisplayNamesToPlayers < ActiveRecord::Migration[5.1] + def change + add_column :players, :display_name, :string + end +end diff --git a/db/migrate/35_add_scored_to_userlevels.rb b/db/migrate/35_add_scored_to_userlevels.rb new file mode 100644 index 00000000..dc238691 --- /dev/null +++ b/db/migrate/35_add_scored_to_userlevels.rb @@ -0,0 +1,8 @@ +class AddScoredToUserlevels < ActiveRecord::Migration[5.1] + def change + add_column :userlevels, :scored, :boolean + + ids = UserlevelScore.distinct.pluck(:userlevel_id) + Userlevel.where(id: ids).update_all(scored: true) + end +end diff --git a/db/migrate/36_fix_secret_names.rb b/db/migrate/36_fix_secret_names.rb new file mode 100644 index 00000000..3c6722a5 --- /dev/null +++ b/db/migrate/36_fix_secret_names.rb @@ -0,0 +1,8 @@ +class FixSecretNames < ActiveRecord::Migration[5.1] + def change + 20.times.each{ |i| + Level.find(1900 + i).update(name: "?-X-#{"%02d" % i}") + Level.find(3100 + i).update(name: "!-X-#{"%02d" % i}") + } + end +end diff --git a/db/migrate/37_fix_screenshot_names.rb b/db/migrate/37_fix_screenshot_names.rb new file mode 100644 index 00000000..1abdb98b --- /dev/null +++ b/db/migrate/37_fix_screenshot_names.rb @@ -0,0 +1,10 @@ +class FixScreenshotNames < ActiveRecord::Migration[5.1] + def change + f = "screenshots/" + (20..23).to_a.product(("A".."E").to_a).map{ |n| n.reverse.join("-") + ".jpg" }.each_with_index{ |name, i| + print("Renaming screenshot #{2 * i} / 40...".ljust(80, " ") + "\r") + File.rename(f + "SS-" + name, f + "SS-X-#{"%02d" % i}.jpg") rescue nil + File.rename(f + "SS2-" + name, f + "SS2-X-#{"%02d" % i}.jpg") rescue nil + } + end +end diff --git a/db/migrate/38_add_dates_to_steam_ids.rb b/db/migrate/38_add_dates_to_steam_ids.rb new file mode 100644 index 00000000..84ec9621 --- /dev/null +++ b/db/migrate/38_add_dates_to_steam_ids.rb @@ -0,0 +1,6 @@ +class AddDatesToSteamIds < ActiveRecord::Migration[5.1] + def change + add_column :users, :last_active, :timestamp + add_column :users, :last_inactive, :timestamp + end +end diff --git a/db/migrate/39_add_twitch_token.rb b/db/migrate/39_add_twitch_token.rb new file mode 100644 index 00000000..922cd0b3 --- /dev/null +++ b/db/migrate/39_add_twitch_token.rb @@ -0,0 +1,5 @@ +class AddTwitchToken < ActiveRecord::Migration[5.1] + def change + GlobalProperty.find_or_create_by(key: 'twitch_token').update(value: nil) + end +end diff --git a/db/migrate/40_create_challenges.rb b/db/migrate/40_create_challenges.rb new file mode 100644 index 00000000..bfdfb338 --- /dev/null +++ b/db/migrate/40_create_challenges.rb @@ -0,0 +1,40 @@ +class CreateChallenges < ActiveRecord::Migration[5.1] + def change + create_table :challenges do |t| + t.references :level, index: true + t.integer :index, limit: 1 + t.integer :g , limit: 1 + t.integer :t , limit: 1 + t.integer :o , limit: 1 + t.integer :c , limit: 1 + t.integer :e , limit: 1 + end + + [ + { filename: "Scodes.txt", id: 600, total: 600 }, + { filename: "SScodes.txt", id: 1800, total: 120 }, + { filename: "S2codes.txt", id: 2400, total: 600 }, + { filename: "SS2codes.txt", id: 3000, total: 120 } + ].each{ |f| + File.read("db/challenges/" + f[:filename]).split("\n").each_with_index{ |l, i| + print("Parsing #{f[:filename][0..-10]} tab: #{i} / #{f[:total]}...".ljust(80, " ") + "\r") + l.split(" ").each_with_index{ |c, j| + objs = { "G" => 0, "T" => 0, "O" => 0, "C" => 0, "E" => 0 } + c.scan(/../).each{ |o| + objs[o[1]] = o[0] == "A" ? 1 : (o[0] == "N" ? -1 : 2) + } + lvl = Level.find(f[:id] + i) + Challenge.find_or_create_by( + level: lvl, + index: j, + g: objs["G"], + t: objs["T"], + o: objs["O"], + c: objs["C"], + e: objs["E"] + ) + } + } + } + end +end diff --git a/db/migrate/41_add_userlevel_tabs.rb b/db/migrate/41_add_userlevel_tabs.rb new file mode 100644 index 00000000..6c02a25a --- /dev/null +++ b/db/migrate/41_add_userlevel_tabs.rb @@ -0,0 +1,13 @@ +# Position of userlevels in each of the userlevel tabs (best, featured, +# top weekly and hardest). +class AddUserlevelTabs < ActiveRecord::Migration[5.1] + def change + create_table :userlevel_tabs do |t| + t.integer :mode, index: true, limit: 1 + t.integer :qt, index: true, limit: 1 + t.integer :index, index: true + t.references :userlevel, index: true + end + GlobalProperty.find_or_create_by(key: 'next_userlevel_tab_update').update(value: Time.now.to_s) + end +end \ No newline at end of file diff --git a/db/migrate/42_add_userlevel_indexes.rb b/db/migrate/42_add_userlevel_indexes.rb new file mode 100644 index 00000000..284d208e --- /dev/null +++ b/db/migrate/42_add_userlevel_indexes.rb @@ -0,0 +1,23 @@ +class AddUserlevelIndexes < ActiveRecord::Migration[5.1] + def change + # Add extra indexes to relevant columns to further enhance performance. + # Mainly required for smooth userlevel browsing. + # Other important notes: + # * Indexes are composite so that we can filter by some columns and sort + # by others, without losing the benefit of the index. + # * In composite indexes the order does matter, that's why the indexes + # below are not redundant. For our needs, we will want to sort by + # one field and possibly filter by several others. In this case, + # the field we sort by should be the first in the composite index, + # and the fields we filter by (in a where clause) come afterwards. + # The order of the latter does not matter, since the where clause + # is commutative (thankfully, otherwise we would've required more + # index combinations). + # * One exception is the ID, which due to being the primary key of the + # table, is a clustered index, and thus we always get the additional + # performance without needing to compose it with other indices. + add_index :userlevels, [:title, :author, :mode] + add_index :userlevels, [:author, :title, :mode] + add_index :userlevels, [:favs, :title, :mode, :author] + end +end diff --git a/db/migrate/43_create_aliases.rb b/db/migrate/43_create_aliases.rb new file mode 100644 index 00000000..be378abb --- /dev/null +++ b/db/migrate/43_create_aliases.rb @@ -0,0 +1,73 @@ +class CreateAliases < ActiveRecord::Migration[5.1] + def change + create_table :level_aliases do |t| + t.references :level + t.string :alias + end + + create_table :player_aliases do |t| + t.references :player + t.string :alias + end + + [ + ['S-C-19-04', '-++' ], + ['S-X-19-04', 'clr' ], + ['!-C-19', 'cfl' ], + ['S-X-13-01', 'sqn' ], + ['S-A-19-03', 'ils' ], + ['!-A-18', 'eeny' ], + ['!-A-18', 'meeny' ], + ['S-E-18-04', '40k' ], + ['SU-B-17-04', 'sss' ], + ['?-C-06', 'slashes'] + ].each{ |l| + Level.find_by(name: l[0]).add_alias(l[1]) + } + + [ + [42585, 'jp' ], + [137646, 'oj' ], + [137646, 'ooj' ], + [121727, 'nw' ], + [41948, 'xaelar' ], + [54303, 'Eddy' ], + [103732, 'Maelstrom'], + [103732, 'maestro' ], + [176553, 'GOK' ], + [176553, 'wolf' ], + [56393, 'maaz' ], + [41759, 'golf' ], + [43409, 'Borlin' ], + [68440, 'Nim' ], + [244029, 'paul' ], + [220878, 'mega' ], + [240323, 'miga' ], + [59984, 'Sim' ], + [287062, 'kk' ], + [139965, 'DS' ], + [116947, 'Sky' ], + [106570, 'Espy' ], + [59993, 'Muz' ], + [117031, 'Mel' ], + [51186, 'Natey' ], + [66067, 'pepsi' ], + [45679, 'Dune' ], + [47266, 'Analu' ], + [286822, 'Mrow' ], + [60861, 'SV' ], + [205571, 'aud' ], + [170323, 'Cheby' ], + [121140, 'Seitaro' ], + [54050, 'CCS' ], + [233161, 'Emm' ], + [162041, 'ps' ], + [57522, 'jroe' ], + [198308, 'Cube' ], + [41836, 'Kost' ], + [202008, 'Raif' ] + ].each{ |p| + Player.find_by(metanet_id: p[0]).add_alias(p[1]) + } + end +end \ No newline at end of file diff --git a/db/migrate/44_create_roles.rb b/db/migrate/44_create_roles.rb new file mode 100644 index 00000000..3c4c4d76 --- /dev/null +++ b/db/migrate/44_create_roles.rb @@ -0,0 +1,9 @@ +class CreateRoles < ActiveRecord::Migration[5.1] + def change + add_column :users, :discord_id, :integer, limit: 8 + create_table :roles do |t| + t.integer :discord_id, limit: 8 + t.string :role + end + end +end \ No newline at end of file diff --git a/db/migrate/45_add_framecount_to_archives.rb b/db/migrate/45_add_framecount_to_archives.rb new file mode 100644 index 00000000..5ee4e4ff --- /dev/null +++ b/db/migrate/45_add_framecount_to_archives.rb @@ -0,0 +1,13 @@ +class AddFramecountToArchives < ActiveRecord::Migration[5.1] + def change + add_column :archives, :framecount, :integer + add_column :archives, :gold, :integer + total = Archive.count + Archive.all.each_with_index{ |a, i| + print("Adding framecount and gold #{i + 1} / #{total}...".ljust(80, ' ') + "\r") + frames = Demo.find(a.id).framecount + gold = frames != -1 ? (((a.score + frames).to_f / 60 - 90) / 2).round : -1 + a.update(framecount: frames, gold: gold) + } + end +end diff --git a/db/migrate/46_add_avatar_to_properties.rb b/db/migrate/46_add_avatar_to_properties.rb new file mode 100644 index 00000000..dc2b4572 --- /dev/null +++ b/db/migrate/46_add_avatar_to_properties.rb @@ -0,0 +1,7 @@ +class AddAvatarToProperties < ActiveRecord::Migration[5.1] + def change + # Can't retrieve current avatar, will be updated whenever it's changed + # That way we don't repeat it when faceswapping + GlobalProperty.find_or_create_by(key: 'avatar').update(value: 'none') + end +end \ No newline at end of file diff --git a/db/migrate/47_add_star_to_scores.rb b/db/migrate/47_add_star_to_scores.rb new file mode 100644 index 00000000..856c86b6 --- /dev/null +++ b/db/migrate/47_add_star_to_scores.rb @@ -0,0 +1,19 @@ +class AddStarToScores < ActiveRecord::Migration[5.1] + def change + add_column :scores, :cool, :boolean, default: false, index: true + add_column :scores, :star, :boolean, default: false, index: true + add_index :archives, :framecount + add_index :archives, :gold + [Level, Episode, Story].each{ |type| + total = type.count + type.all.each_with_index{ |h, i| + print("Adding cool and star to #{type.to_s.downcase} #{i + 1} / #{total}...".ljust(80, ' ') + "\r") + coolness = h.find_coolness + stars = Archive.zeroths(h) + h.scores.where("rank < #{coolness}").update_all(cool: true) + h.scores.joins("INNER JOIN players ON players.id = scores.player_id") + .where({ "players.metanet_id" => stars }).update_all(star: true) + } + } + end +end \ No newline at end of file diff --git a/db/migrate/48_create_userlevel_authors.rb b/db/migrate/48_create_userlevel_authors.rb new file mode 100644 index 00000000..5828cbf6 --- /dev/null +++ b/db/migrate/48_create_userlevel_authors.rb @@ -0,0 +1,30 @@ +# Substitute author field in userlevels with a reference to a table with all +# userlevel authors +# Also keep track of old names ("akas") +class CreateUserlevelAuthors < ActiveRecord::Migration[5.1] + def change + rename_column :userlevels, :author, :author_temp + + create_table :userlevel_authors do |t| + t.string :name, index: true + end + + create_table :userlevel_akas do |t| + t.integer :author_id, index: true + t.timestamp :date + t.string :name + end + + ActiveRecord::Base.transaction do + count = Userlevel.count.to_i + Userlevel.order(id: :asc).pluck(:author_id, :author_temp, :date).each_with_index{ |u, i| + print("Creating userlevel author and aka for userlevel #{i + 1} / #{count}...".ljust(80, ' ') + "\r") + date = Time.strptime(u[2], "%d/%m/%y %H:%M").strftime(DATE_FORMAT_MYSQL) + UserlevelAuthor.where(id: u[0]).first_or_create(id: u[0]).rename(u[1], date) + } + end + puts "" + + remove_column :userlevels, :author_temp + end +end diff --git a/db/migrate/49_improve_userlevel_dates.rb b/db/migrate/49_improve_userlevel_dates.rb new file mode 100644 index 00000000..2d6561b5 --- /dev/null +++ b/db/migrate/49_improve_userlevel_dates.rb @@ -0,0 +1,26 @@ +require 'time' + +# Reformat date to timestamp rather than a string +# Add dates for last update of scores and map properties +class ImproveUserlevelDates < ActiveRecord::Migration[5.1] + def change + # For some STUPID reason, renaming timestamp columns doesn't work properly + # So we have to use raw SQL, it's technical + ActiveRecord::Base.connection.execute("ALTER TABLE `userlevels` CHANGE `last_update` `score_update` timestamp NULL DEFAULT NULL") + rename_column :userlevels, :date, :date_temp + add_column :userlevels, :date, :timestamp, index: true + add_column :userlevels, :map_update, :timestamp, index: true + add_index :userlevels, :score_update + + count = Userlevel.count.to_i + ActiveRecord::Base.transaction do + Userlevel.all.each_with_index{ |u, i| + print "Reformatting date #{i + 1} / #{count}...".ljust(80, ' ') + "\r" + u.update(date: Time.strptime(u.date_temp, "%d/%m/%y %H:%M").strftime(DATE_FORMAT_MYSQL)) + } + end + puts "" + + remove_column :userlevels, :date_temp + end +end diff --git a/db/migrate/50_create_mappacks.rb b/db/migrate/50_create_mappacks.rb new file mode 100644 index 00000000..6b6259ed --- /dev/null +++ b/db/migrate/50_create_mappacks.rb @@ -0,0 +1,79 @@ +# First basic implementation of mappack support +class CreateMappacks < ActiveRecord::Migration[5.1] + def change + create_table :mappacks do |t| + t.string :name + t.string :code + t.string :authors + t.timestamp :date + end + + create_table :mappack_data do |t| + t.binary :tile_data + t.binary :object_data, limit: 1024 ** 2 + end + + create_table :mappack_levels do |t| + t.integer :inner_id, index: true, limit: 2 + t.integer :mappack_id, index: true, limit: 2 + t.integer :mode, index: true, limit: 1 + t.integer :tab, index: true, limit: 1 + t.integer :episode_id, index: true + t.string :name + t.string :longname, index: true + end + + create_table :mappack_episodes do |t| + t.integer :inner_id, index: true, limit: 2 + t.integer :mappack_id, index: true, limit: 2 + t.integer :mode, index: true, limit: 1 + t.integer :tab, index: true, limit: 1 + t.integer :story_id, index: true + t.string :name + end + + create_table :mappack_stories do |t| + t.integer :inner_id, index: true, limit: 2 + t.integer :mappack_id, index: true, limit: 2 + t.integer :mode, index: true, limit: 1 + t.integer :tab, index: true, limit: 1 + t.string :name + end + + create_table :mappack_scores do |t| + t.integer :rank, index: true, limit: 1 + t.integer :tied_rank, index: true, limit: 1 + t.integer :tab, index: true, limit: 1 + t.integer :player_id, index: true + t.integer :mappack_id, index: true + t.integer :highscoreable_id, index: true + t.string :highscoreable_type, index: true + t.integer :archive_id + t.integer :score + end + + create_table :mappack_archives do |t| + t.integer :player_id, index: true + t.integer :metanet_id, index: true + t.integer :highscoreable_id, index: true + t.string :highscoreable_type, index: true + t.timestamp :date, index: true + t.boolean :expired, index: true + t.integer :score + end + + create_table :mappack_demos do |t| + t.binary :demo + end + + create_table :mappack_challenges do |t| + t.integer :level_id, index: true + t.integer :index, limit: 1 + t.integer :g, limit: 1 + t.integer :t, limit: 1 + t.integer :o, limit: 1 + t.integer :c, limit: 1 + t.integer :e, limit: 1 + end + end +end \ No newline at end of file diff --git a/db/migrate/51_change_tabs.rb b/db/migrate/51_change_tabs.rb new file mode 100644 index 00000000..fda4013d --- /dev/null +++ b/db/migrate/51_change_tabs.rb @@ -0,0 +1,30 @@ +# With this migration we do 2 things: +# 1) Reformat tab field to tinyint (1 byte) +# 2) Change tab indexes according to the formula the game uses: +# 7 * m + t (m = mode, t = tab) + +def qstr(tab, type, field) + "#{field} >= #{tab[:start] / 5 ** type} AND #{field} < #{(tab[:start] + tab[:size]) / 5 ** type}" +end + +class ChangeTabs < ActiveRecord::Migration[5.1] + def change + # Reformat tab column to tinyint (1 byte) + change_column :levels, :tab, :integer, limit: 1 + change_column :episodes, :tab, :integer, limit: 1 + change_column :stories, :tab, :integer, limit: 1 + + # Change tab indexes + TABS_NEW.each{ |k, v| + tab = v[:mode] * 7 + v[:tab] + types = [Level, Episode, Story] + types.each_with_index{ |t, i| + t.where(qstr(v, i, 'id')).update_all(tab: tab) + [Score, Archive].each{ |c| + c.where(highscoreable_type: t.to_s) + .where(qstr(v, i, 'highscoreable_id')).update_all(tab: tab) + } + } + } + end +end \ No newline at end of file diff --git a/db/migrate/52_move_expired.rb b/db/migrate/52_move_expired.rb new file mode 100644 index 00000000..2dfcacd6 --- /dev/null +++ b/db/migrate/52_move_expired.rb @@ -0,0 +1,20 @@ +# Move "expired" field from Demos to Archives, makes more sense +# Also delete "replay_id" and "htype" from Demos, the former is redundant and +# the second is useless. +class MoveExpired < ActiveRecord::Migration[5.1] + def change + add_column :archives, :lost, :boolean, index: true, default: false + count = Demo.count + [true, false].each{ |b| + Archive.joins("INNER JOIN demos ON archives.id = demos.id") + .where("demos.expired = #{b}") + .update_all(lost: b) + } + puts "" + change_table :demos do |t| + t.remove :replay_id + t.remove :expired + t.remove :htype + end + end +end \ No newline at end of file diff --git a/db/migrate/53_add_expired_to_archives.rb b/db/migrate/53_add_expired_to_archives.rb new file mode 100644 index 00000000..31da2882 --- /dev/null +++ b/db/migrate/53_add_expired_to_archives.rb @@ -0,0 +1,13 @@ +class AddExpiredToArchives < ActiveRecord::Migration[5.1] + def change + add_column :archives, :expired, :boolean, index: true, default: false + count = Archive.count + Archive.order(id: :asc).each_with_index{ |ar, i| + print("Calculating expiration for archive #{i + 1} / #{count}...".ljust(80, ' ') + "\r") + Archive.where(highscoreable: ar.highscoreable, player: ar.player) + .where("id < ?", ar.id) + .update_all(expired: true) + } + puts '' + end +end \ No newline at end of file diff --git a/db/migrate/54_simplify_mappacks.rb b/db/migrate/54_simplify_mappacks.rb new file mode 100644 index 00000000..df766dd1 --- /dev/null +++ b/db/migrate/54_simplify_mappacks.rb @@ -0,0 +1,41 @@ +# Remove archives, they don't make as much sense here since we have access to +# all submitted scores. +# Also, add speedrun fields, and rename hs ones accordingly. +class SimplifyMappacks < ActiveRecord::Migration[5.1] + def change + # Remove archives table + drop_table :mappack_archives + + # Revamp scores table + change_table :mappack_scores do |t| + # Remove archive reference + t.remove :archive_id + + # Rename hs fields explicitly + t.rename :rank, :rank_hs + t.rename :tied_rank, :tied_rank_hs + t.rename :score, :score_hs + + # Change hs ranks to shorts (2 bytes) + t.change :rank_hs, :integer, index: true, limit: 2 + t.change :tied_rank_hs, :integer, index: true, limit: 2 + + # Create sr fields + t.integer :rank_sr, limit: 2 + t.integer :tied_rank_sr, limit: 2 + t.integer :score_sr + + # Add sr indices (needs to be done separately, for some reason) + t.index :rank_sr + t.index :tied_rank_sr + + # Create archive-esque fields + t.integer :metanet_id + t.timestamp :date + + # Add indices + t.index :metanet_id + t.index :date + end + end +end \ No newline at end of file diff --git a/db/migrate/55_change_mappack_score_ids.rb b/db/migrate/55_change_mappack_score_ids.rb new file mode 100644 index 00000000..d2826fd8 --- /dev/null +++ b/db/migrate/55_change_mappack_score_ids.rb @@ -0,0 +1,7 @@ +class ChangeMappackScoreIds < ActiveRecord::Migration[5.1] + def change + MappackScore.order(id: :desc).update_all("id = id + #{MIN_REPLAY_ID}") + MappackDemo.order(id: :desc).update_all("id = id + #{MIN_REPLAY_ID}") + MappackScore.find_or_create_by(id: MIN_REPLAY_ID) + end +end diff --git a/db/migrate/56_create_mappack_scores_tweaks.rb b/db/migrate/56_create_mappack_scores_tweaks.rb new file mode 100644 index 00000000..a7e8f241 --- /dev/null +++ b/db/migrate/56_create_mappack_scores_tweaks.rb @@ -0,0 +1,12 @@ +# Read the corresponding class definition in the mappacks file to understand +# what tweaks are and why they're necessary. +class CreateMappackScoresTweaks < ActiveRecord::Migration[5.1] + def change + create_table :mappack_scores_tweaks do |t| + t.integer :player_id, index: true + t.integer :episode_id, index: true + t.integer :index + t.integer :tweak + end + end +end diff --git a/db/migrate/57_add_gold_to_mappack_scores.rb b/db/migrate/57_add_gold_to_mappack_scores.rb new file mode 100644 index 00000000..6cfb3779 --- /dev/null +++ b/db/migrate/57_add_gold_to_mappack_scores.rb @@ -0,0 +1,16 @@ +class AddGoldToMappackScores < ActiveRecord::Migration[5.1] + def change + change_table :mappack_scores do |t| + t.integer :gold, index: true, limit: 2 + end + + count = MappackScore.count + MappackScore.all.each_with_index{ |s, i| + print("Calculating gold count for score #{i + 1} / #{count}...".ljust(80, ' ') + "\r") + next if s.id == 131072 + gold = MappackScore.gold_count(s.highscoreable_type, s.score_hs, s.score_sr) + alert("Potentially incorrect hs score at #{s.id}") if !MappackScore.verify_gold(gold) + s.update(gold: gold.round) + } + end +end diff --git a/db/migrate/58_add_steam_actives.rb b/db/migrate/58_add_steam_actives.rb new file mode 100644 index 00000000..aed62357 --- /dev/null +++ b/db/migrate/58_add_steam_actives.rb @@ -0,0 +1,10 @@ +class AddSteamActives < ActiveRecord::Migration[5.1] + def change + change_table :users do |t| + t.remove :last_inactive + t.boolean :active, index: true + end + + GlobalProperty.update_steam_actives + end +end \ No newline at end of file diff --git a/db/migrate/59_add_default_palettes.rb b/db/migrate/59_add_default_palettes.rb new file mode 100644 index 00000000..4d7c8c26 --- /dev/null +++ b/db/migrate/59_add_default_palettes.rb @@ -0,0 +1,5 @@ +class AddDefaultPalettes < ActiveRecord::Migration[5.1] + def change + add_column :users, :palette, :string + end +end \ No newline at end of file diff --git a/db/migrate/60_update_level_names.rb b/db/migrate/60_update_level_names.rb new file mode 100644 index 00000000..d8df2cb3 --- /dev/null +++ b/db/migrate/60_update_level_names.rb @@ -0,0 +1,18 @@ +class UpdateLevelNames < ActiveRecord::Migration[5.1] + def change + count = Level.all.count + changes = 0 + trivial = 0 + Level.all.each_with_index{ |l, i| + print("Checking level #{"%3d" % [i + 1]} / #{count}...\r") + old_name = l.longname + new_name = l.map.longname + if old_name != new_name + old_name.strip == new_name.strip ? trivial += 1 : changes += 1 + l.update(longname: new_name) + end + } + puts + puts "Found #{changes + trivial} changes (#{changes} normal, #{trivial} trivial)." + end +end \ No newline at end of file diff --git a/db/migrate/61_add_story_associations.rb b/db/migrate/61_add_story_associations.rb new file mode 100644 index 00000000..edf38bed --- /dev/null +++ b/db/migrate/61_add_story_associations.rb @@ -0,0 +1,9 @@ +class AddStoryAssociations < ActiveRecord::Migration[5.1] + def change + add_column :episodes, :story_id, :integer + + Story.all.each{ |s| + Episode.where('id DIV 5 = ?', s.id).update_all(story_id: s.id) + } + end +end \ No newline at end of file diff --git a/db/migrate/62_add_mappack_versions.rb b/db/migrate/62_add_mappack_versions.rb new file mode 100644 index 00000000..3ab09930 --- /dev/null +++ b/db/migrate/62_add_mappack_versions.rb @@ -0,0 +1,6 @@ +class AddMappackVersions < ActiveRecord::Migration[5.1] + def change + add_column :mappacks, :version, :integer + Mappack.update_all(version: 1) + end +end \ No newline at end of file diff --git a/db/migrate/63_add_mappack_authors.rb b/db/migrate/63_add_mappack_authors.rb new file mode 100644 index 00000000..2be1113a --- /dev/null +++ b/db/migrate/63_add_mappack_authors.rb @@ -0,0 +1,6 @@ +class AddMappackAuthors < ActiveRecord::Migration[5.1] + def change + add_column :mappack_levels, :author, :string + Mappack.find_by(code: 'ctp').read_authors + end +end \ No newline at end of file diff --git a/db/migrate/64_add_ctp_lotd.rb b/db/migrate/64_add_ctp_lotd.rb new file mode 100644 index 00000000..e7c3fc76 --- /dev/null +++ b/db/migrate/64_add_ctp_lotd.rb @@ -0,0 +1,16 @@ +class AddCtpLotd < ActiveRecord::Migration[5.1] + def change + add_column :mappack_levels, :completed, :boolean + add_column :mappack_episodes, :completed, :boolean + add_column :mappack_stories, :completed, :boolean + ['level', 'episode', 'story'].each{ |type| + time = GlobalProperty.find_by(key: "next_#{type}_update").value + GlobalProperty.find_or_create_by(key: "next_ctp_#{type}_update") + .update(value: time) + GlobalProperty.find_or_create_by(key: "current_ctp_#{type}") + .update(value: nil) + GlobalProperty.find_or_create_by(key: "saved_ctp_#{type}_scores") + .update(value: []) + } + end +end \ No newline at end of file diff --git a/db/migrate/65_create_bad_hashes.rb b/db/migrate/65_create_bad_hashes.rb new file mode 100644 index 00000000..41565599 --- /dev/null +++ b/db/migrate/65_create_bad_hashes.rb @@ -0,0 +1,10 @@ +# Create a table to store incorrectly computed SHA1 hashes, so that we can hopefully +# figure out a pattern +class CreateBadHashes < ActiveRecord::Migration[5.1] + def change + create_table :bad_hashes do |t| + t.integer :score + t.binary :npp_hash, limit: 20 + end + end +end \ No newline at end of file diff --git a/db/migrate/66_add_personal_packs.rb b/db/migrate/66_add_personal_packs.rb new file mode 100644 index 00000000..e375f572 --- /dev/null +++ b/db/migrate/66_add_personal_packs.rb @@ -0,0 +1,17 @@ +# Users can choose a default mappack. Then, whenever a highscoreable is parsed +# from their message, it will be assumed to be from that mappack, rather than +# vanilla N++, thus saving the effort to write the 3 mappack code letters. +# +# mappack_id - Mappack to use as global default +# mappack_defaults - Use defaults for each channel (e.g. HAX in HAX channel) +# mappack_default_always - Use global default always +# mappacK_default_dms - Use global default only on DMs + +class AddPersonalPacks < ActiveRecord::Migration[5.1] + def change + add_column :users, :mappack_id, :integer, default: 0 + add_column :users, :mappack_defaults, :boolean, default: false + add_column :users, :mappack_default_always, :boolean, default: false + add_column :users, :mappack_default_dms, :boolean, default: false + end +end \ No newline at end of file diff --git a/db/migrate/67_add_mappack_channels.rb b/db/migrate/67_add_mappack_channels.rb new file mode 100644 index 00000000..fd3c1c76 --- /dev/null +++ b/db/migrate/67_add_mappack_channels.rb @@ -0,0 +1,11 @@ +# We store a list of Discord channel IDs for each mappack, corresponding to the +# server channels that are dedicated to this mappack. This will be used when +# determining how highscoreable IDs are parsed by default for each player. +class AddMappackChannels < ActiveRecord::Migration[5.1] + def change + create_table :mappack_channels do |t| + t.integer :mappack_id, index: true + t.string :name + end + end +end \ No newline at end of file diff --git a/db/migrate/68_add_mappack_lotds.rb b/db/migrate/68_add_mappack_lotds.rb new file mode 100644 index 00000000..7c36131b --- /dev/null +++ b/db/migrate/68_add_mappack_lotds.rb @@ -0,0 +1,8 @@ +# Add column to mappacks table that specifies whether there are lotds for that +# mappack +class AddMappackLotds < ActiveRecord::Migration[5.1] + def change + add_column :mappacks, :lotd, :boolean, default: false + Mappack.where(code: ['met', 'ctp']).update_all(lotd: true) + end +end \ No newline at end of file diff --git a/db/migrate/69_refactor_users.rb b/db/migrate/69_refactor_users.rb new file mode 100644 index 00000000..b577bc1d --- /dev/null +++ b/db/migrate/69_refactor_users.rb @@ -0,0 +1,17 @@ +# Refactor the users table to make it simpler and more robust, by referencing +# the corresponding player by ID, uniformizing column names, etc. +class RefactorUsers < ActiveRecord::Migration[5.1] + def change + add_column :users, :player_id, :integer + rename_column :users, :username, :name + remove_column :users, :displayname + + User.where.not(playername: nil).each{ |u| + player = Player.find_by(name: u.playername) + next if !player + u.update(player_id: player.id) + } + + remove_column :users, :playername + end +end \ No newline at end of file diff --git a/db/migrate/70_refactor_players.rb b/db/migrate/70_refactor_players.rb new file mode 100644 index 00000000..60ed7e68 --- /dev/null +++ b/db/migrate/70_refactor_players.rb @@ -0,0 +1,34 @@ +# Move the Steam ID field over to the Players table, which makes more sense +class RefactorPlayers < ActiveRecord::Migration[5.1] + def change + add_column :players, :steam_id, :string + add_column :players, :last_active, :timestamp + add_column :players, :active, :boolean + Player.reset_column_information + + # Find players and userlevel_players + User.where.not(steam_id: nil).each{ |u| + if u.player + u.player.update( + steam_id: u.steam_id, + last_active: u.last_active, + active: u.active + ) + else + up = UserlevelPlayer.find_by(name: u.name) + next if !up + Player.where(metanet_id: up.metanet_id) + .first_or_create(name: up.name) + .update( + steam_id: u.steam_id, + last_active: u.last_active, + active: u.active + ) + end + } + + remove_column :users, :steam_id + remove_column :users, :last_active + remove_column :users, :active + end +end \ No newline at end of file diff --git a/db/migrate/71_add_messages.rb b/db/migrate/71_add_messages.rb new file mode 100644 index 00000000..a7eb0b48 --- /dev/null +++ b/db/migrate/71_add_messages.rb @@ -0,0 +1,10 @@ +# Create a db table to log each message outte sends and who it is in response to +# That way, that user may later request to delete that message +class AddMessages < ActiveRecord::Migration[5.1] + def change + create_table :messages do |t| + t.integer :user_id, limit: 8, index: true + t.timestamp :date + end + end +end \ No newline at end of file diff --git a/db/migrate/72_add_completions.rb b/db/migrate/72_add_completions.rb new file mode 100644 index 00000000..c1678607 --- /dev/null +++ b/db/migrate/72_add_completions.rb @@ -0,0 +1,12 @@ +# Add field to hold completion count for each highscoreable +class AddCompletions < ActiveRecord::Migration[5.1] + def change + add_column :levels, :completions, :integer, index: true + add_column :episodes, :completions, :integer, index: true + add_column :stories, :completions, :integer, index: true + add_column :mappack_levels, :completions, :integer, index: true + add_column :mappack_episodes, :completions, :integer, index: true + add_column :mappack_stories, :completions, :integer, index: true + add_column :userlevels, :completions, :integer, index: true + end +end \ No newline at end of file diff --git a/db/migrate/73_add_outte_player.rb b/db/migrate/73_add_outte_player.rb new file mode 100644 index 00000000..9d0b720a --- /dev/null +++ b/db/migrate/73_add_outte_player.rb @@ -0,0 +1,8 @@ +class AddOuttePlayer < ActiveRecord::Migration[5.1] + def change + Player.find_or_create_by(metanet_id: OUTTE_ID).update( + name: 'outte++', + steam_id: OUTTE_STEAM_ID.to_s + ) + end +end diff --git a/db/migrate/74_add_metanet_modes.rb b/db/migrate/74_add_metanet_modes.rb new file mode 100644 index 00000000..62645ca0 --- /dev/null +++ b/db/migrate/74_add_metanet_modes.rb @@ -0,0 +1,14 @@ +# Add playing mode (Solo, Coop, Race) to Metanet highscoreables, so that: +# - We can define certain methods, that depend on the mode, for all Highscoreables +# - Future-proof the db, in case we ever implement the other modes +class AddMetanetModes < ActiveRecord::Migration[5.1] + def change + add_column :levels, :mode, :integer, index: true + add_column :episodes, :mode, :integer, index: true + add_column :stories, :mode, :integer, index: true + + Level.update_all(mode: 0) + Episode.update_all(mode: 0) + Story.update_all(mode: 0) + end +end \ No newline at end of file diff --git a/db/migrate/75_add_mappack_data_versions.rb b/db/migrate/75_add_mappack_data_versions.rb new file mode 100644 index 00000000..32733e1a --- /dev/null +++ b/db/migrate/75_add_mappack_data_versions.rb @@ -0,0 +1,7 @@ +class AddMappackDataVersions < ActiveRecord::Migration[5.1] + def change + add_column :mappack_data, :highscoreable_id, :integer, index: true + add_column :mappack_data, :version, :integer + MappackData.delete_all + end +end \ No newline at end of file diff --git a/db/migrate/76_add_userlevel_caches.rb b/db/migrate/76_add_userlevel_caches.rb new file mode 100644 index 00000000..a2a3b2c1 --- /dev/null +++ b/db/migrate/76_add_userlevel_caches.rb @@ -0,0 +1,15 @@ +# Userlevel query cache +# key: SHA1 hash of the string formed by concatenating the userlevel IDs in this query +# result: Resulting map collection, ready to be sent to N++ +# date: Timestamp, so that we can delete expired query results +class AddUserlevelCaches < ActiveRecord::Migration[5.1] + def change + create_table :userlevel_caches do |t| + t.string :key + t.binary :result + t.timestamp :date + end + + add_column :users, :query, :integer, index: true + end +end \ No newline at end of file diff --git a/db/migrate/77_add_gold_counts.rb b/db/migrate/77_add_gold_counts.rb new file mode 100644 index 00000000..7536f8e7 --- /dev/null +++ b/db/migrate/77_add_gold_counts.rb @@ -0,0 +1,9 @@ +# Add fields to hold total gold count for each mappack highscoreable, so that +# it can be quickly compared against run's gold count to detect G++ runs +class AddGoldCounts < ActiveRecord::Migration[5.1] + def change + add_column :mappack_levels, :gold, :integer, limit: 2 + add_column :mappack_episodes, :gold, :integer, limit: 2 + add_column :mappack_stories, :gold, :integer, limit: 2 + end +end \ No newline at end of file diff --git a/db/migrate/78_add_manual_replay_id.rb b/db/migrate/78_add_manual_replay_id.rb new file mode 100644 index 00000000..9bef5b8f --- /dev/null +++ b/db/migrate/78_add_manual_replay_id.rb @@ -0,0 +1,8 @@ +# Add configurable replay ID field so that the botmaster can send specific runs +# to the leaderboards via CLE. This is useful for when a run is not in the +# leaderboards, perhaps it's even obsolete, but we want to watch it somehow. +class AddManualReplayId < ActiveRecord::Migration[5.1] + def change + GlobalProperty.find_or_create_by(key: 'replay_id').update(value: nil) + end +end \ No newline at end of file diff --git a/db/migrate/79_add_userlevel_submitted.rb b/db/migrate/79_add_userlevel_submitted.rb new file mode 100644 index 00000000..b58d2e5b --- /dev/null +++ b/db/migrate/79_add_userlevel_submitted.rb @@ -0,0 +1,10 @@ +# Add field to hold whether an outte run has been submitted to a specific userlevel. +# We do this so that we only submit scores to levels that require it in order to +# see the full completion count, without flooding all userlevels with unnecessary +# outte scores. +class AddUserlevelSubmitted < ActiveRecord::Migration[5.1] + def change + add_column :userlevels, :submitted, :boolean, index: true, default: false + change_column :userlevels, :completions, :integer, index: true, default: 0 + end +end diff --git a/db/migrate/80_add_mappack_hashes.rb b/db/migrate/80_add_mappack_hashes.rb new file mode 100644 index 00000000..8e8f6b28 --- /dev/null +++ b/db/migrate/80_add_mappack_hashes.rb @@ -0,0 +1,14 @@ +# Create a dedicated table to store the precomputed SHA1 hashes for all +# mappack levels, episodes and stories, and for all their versions. +# This is what actually takes the most time during integrity checks, so we +# can save a lot with this approach. +class AddMappackHashes < ActiveRecord::Migration[5.1] + def change + create_table :mappack_hashes do |t| + t.integer :highscoreable_id, index: true + t.string :highscoreable_type, index: true + t.integer :version, limit: 1 + t.binary :sha1_hash, limit: 100 + end + end +end \ No newline at end of file diff --git a/db/migrate/81_archive_cheated_scores.rb b/db/migrate/81_archive_cheated_scores.rb new file mode 100644 index 00000000..e9ea51b2 --- /dev/null +++ b/db/migrate/81_archive_cheated_scores.rb @@ -0,0 +1,6 @@ +# Store cheated runs in the db for archival and reference, appropriately flagged +class ArchiveCheatedScores < ActiveRecord::Migration[5.1] + def change + add_column :archives, :cheated, :boolean, default: false + end +end diff --git a/db/migrate/82_add_fractional_scores.rb b/db/migrate/82_add_fractional_scores.rb new file mode 100644 index 00000000..0e633d2a --- /dev/null +++ b/db/migrate/82_add_fractional_scores.rb @@ -0,0 +1,8 @@ +# Store interpolated fractional scores using NSim +class AddFractionalScores < ActiveRecord::Migration[5.1] + def change + add_column :archives, :fraction, :double, default: nil + add_column :mappack_scores, :fraction, :double, default: nil + add_column :userlevel_scores, :fraction, :double, default: nil + end +end diff --git a/db/migrate/83_add_mappack_visibility.rb b/db/migrate/83_add_mappack_visibility.rb new file mode 100644 index 00000000..8ed18f8c --- /dev/null +++ b/db/migrate/83_add_mappack_visibility.rb @@ -0,0 +1,7 @@ +# Store interpolated fractional scores using NSim +class AddMappackVisibility < ActiveRecord::Migration[5.1] + def change + add_column :mappacks, :enabled, :boolean, default: true + add_column :mappacks, :public, :boolean, default: true + end +end diff --git a/db/migrate/84_change_fraction_default.rb b/db/migrate/84_change_fraction_default.rb new file mode 100644 index 00000000..bd010c17 --- /dev/null +++ b/db/migrate/84_change_fraction_default.rb @@ -0,0 +1,31 @@ +# NSim's fraction returns how much of the last frame is remaining. +# Instead, we want to store how much has elapsed (its simpler to deal with +# for multiple reasons), so we change the current contents of the table. +# The elapsed frame is in [0, 1), it cannot be 1, as that would mean collision +# hasn't taken place on this frame. Therefore, we can use 1 as a default for when: +# - The fractional frame has not been computed yet (used to be NULL). +# - Its computation has failed (used to be -1). +# The result is that those people's frac score will lose 1 frame, instead of having +# to deal with NULL's all over the place, and we can still distinguish them. +# The only consequence is that we can no longer distinguish between a fractional +# score missing because it hasn't been computed yet or because its computation failed, +# but this is hardly ever relevant. +class ChangeFractionDefault < ActiveRecord::Migration[5.1] + def change + [Archive, MappackScore, UserlevelScore].each do |klass| + # Change current valid values from "remaining time" to "elapsed time" + klass.where('`fraction` >= 0 AND `fraction` <= 1').update_all('`fraction` = 1 - `fraction`') + + # Change missing values from NULL to 1 + klass.where(fraction: nil).update_all(fraction: 1) + + # Change incorrectly computed values from -1 to 1 + klass.where(fraction: -1).update_all(fraction: 1) + end + + # Change column defaults + change_column_default :archives, :fraction, from: nil, to: 1 + change_column_default :mappack_scores, :fraction, from: nil, to: 1 + change_column_default :userlevel_scores, :fraction, from: nil, to: 1 + end +end diff --git a/db/migrate/85_more_fractional_stuff.rb b/db/migrate/85_more_fractional_stuff.rb new file mode 100644 index 00000000..8fe8742e --- /dev/null +++ b/db/migrate/85_more_fractional_stuff.rb @@ -0,0 +1,12 @@ +class MoreFractionalStuff < ActiveRecord::Migration[5.1] + def change + add_column :mappacks, :fractional, :boolean, default: false + add_column :archives, :simulated, :boolean, default: false + add_column :mappack_scores, :simulated, :boolean, default: false + add_column :userlevel_scores, :simulated, :boolean, default: false + Player.find_or_create_by(name: DEV_PLAYER_NAME) + [Archive, MappackScore, UserlevelScore].each do |klass| + klass.where.not(fraction: 1).update_all(simulated: true) + end + end +end diff --git a/db/migrate/86_add_dev_scores.rb b/db/migrate/86_add_dev_scores.rb new file mode 100644 index 00000000..e45f7ad3 --- /dev/null +++ b/db/migrate/86_add_dev_scores.rb @@ -0,0 +1,12 @@ +# Add fields to store special "Developer Scores" for mappack highscoreables so +# that players have an optional score to aim for. +class AddDevScores < ActiveRecord::Migration[5.1] + def change + add_column :mappack_levels, :dev_hs, :float + add_column :mappack_episodes, :dev_hs, :float + add_column :mappack_stories, :dev_hs, :float + add_column :mappack_levels, :dev_sr, :integer + add_column :mappack_episodes, :dev_sr, :integer + add_column :mappack_stories, :dev_sr, :integer + end +end diff --git a/db/migrate/87_add_speedrun_tracking.rb b/db/migrate/87_add_speedrun_tracking.rb new file mode 100644 index 00000000..eb45ecc3 --- /dev/null +++ b/db/migrate/87_add_speedrun_tracking.rb @@ -0,0 +1,11 @@ +# Track dates for the last speedruns notified in Discord +class AddSpeedrunTracking < ActiveRecord::Migration[5.1] + def change + ['new', 'verified', 'rejected'].each{ |status| + Speedrun::GAMES.each{ |id, name| + GlobalProperty.find_or_create_by(key: "last_#{status}_#{id}_speedrun") + .update(value: Time.now.to_s) + } + } + end +end diff --git a/db/migrate/88_add_secondary_outte.rb b/db/migrate/88_add_secondary_outte.rb new file mode 100644 index 00000000..63043005 --- /dev/null +++ b/db/migrate/88_add_secondary_outte.rb @@ -0,0 +1,8 @@ +class AddSecondaryOutte < ActiveRecord::Migration[5.1] + def change + Player.find_or_create_by(metanet_id: OUTTE2_ID).update( + name: 'outte++2', + steam_id: OUTTE2_STEAM_ID.to_s + ) + end +end diff --git a/db/migrate/89_add_raw_tables.rb b/db/migrate/89_add_raw_tables.rb new file mode 100644 index 00000000..677472bc --- /dev/null +++ b/db/migrate/89_add_raw_tables.rb @@ -0,0 +1,26 @@ +# Additional tables to mirror the entire Metanet db as closely as possible, +# they are intended for archival only, and they are separate so the +# queries for highscoring stats are still performed on much smaller tables. +class AddRawTables < ActiveRecord::Migration[5.1] + def change + create_table :raw_players, id: :integer do |t| + t.string :name + end + + create_table :raw_scores, id: :integer do |t| + t.integer :replay_id, index: true + t.integer :player_id, index: true + t.integer :rank, index: true + t.integer :tied_rank, index: true + t.integer :highscoreable_type, limit: 1 + t.integer :highscoreable_id + t.integer :score + end + + create_table :raw_demos, id: :integer do |t| + t.binary :demo + end + + add_index :raw_scores, [:highscoreable_type, :highscoreable_id] + end +end diff --git a/db/migrate/90_add_steam_tickets.rb b/db/migrate/90_add_steam_tickets.rb new file mode 100644 index 00000000..bcb1737d --- /dev/null +++ b/db/migrate/90_add_steam_tickets.rb @@ -0,0 +1,15 @@ +# New table to store Steam session authentication tickets. These tickets are +# generated using Steamwork's API and are linked to a user/app pair, and once +# validated by Steam they can be used to prove ownership of the app as well as +# authenticate. If we send it to N++'s server we enable requests for that account +# (i.e. we activate the Steam ID) for an hour. See the SteamTicket class for more info. +class AddSteamTickets < ActiveRecord::Migration[5.1] + def change + create_table :steam_tickets, id: :integer do |t| + t.integer :app_id + t.integer :steam_id, limit: 8 + t.binary :ticket + t.timestamp :date + end + end +end diff --git a/db/migrate/91_add_tenpp.rb b/db/migrate/91_add_tenpp.rb new file mode 100644 index 00000000..62977af6 --- /dev/null +++ b/db/migrate/91_add_tenpp.rb @@ -0,0 +1,58 @@ +# Add the new levels and challenges introduced in the TEN++ update to celebrate +# the game's release 10th anniversary. As usual, we only include Solo here, the +# other stuff has been added to the mappack folder though, so a simple update +# will fetch those in too. The new solo levels are visually split in 2 tabs, +# but internally they're a single tab (index 6, codenamed "DLC"), so that's +# what we do here as well, to prevent breaking compatibility. +class AddTenpp < ActiveRecord::Migration[5.1] + def change + dir = Mappack.find(0).folder(v: 2) + tab = TABS_NEW[:ST] + tab_id = tab[:mode] * 7 + tab[:tab] + letters = ('A'..'Z').to_a.reject{ |letter| letter == 'N' } + + # Parse level data + level_id = tab[:start] + tab[:files].each{ |filename, count| + code = filename == 'SSS' ? '?!' : '!?' + path = File.join(dir, filename + '.txt') + File.read(path).scan(/\$(.+?)#/).map(&:first).each_with_index{ |title, i| + Level.find_or_create_by(id: level_id).update( + name: code + '-' + letters[i], + completed: false, + longname: title.strip, + tab: tab_id, + completions: 0, + mode: tab[:mode] + ) + level_id += 1 + } + } + + # Parse challenge data + level_id = tab[:start] + tab[:files].each{ |filename, count| + path = File.join(DIR_CHALLENGES, filename + 'codes.txt') + maps = File.read(path).split("\n") + maps.each_with_index{ |map, i| + print("Parsing #{filename} challenges: #{i + 1} / #{maps.size}...".ljust(80, " ") + "\r") + map.squish.split(" ").each_with_index{ |c, j| + objs = { "G" => 0, "T" => 0, "O" => 0, "C" => 0, "E" => 0 } + c.scan(/../).each{ |o| + objs[o[1]] = o[0] == "A" ? 1 : o[0] == "N" ? -1 : 2 + } + Challenge.find_or_create_by( + level_id: level_id, + index: j, + g: objs["G"], + t: objs["T"], + o: objs["O"], + c: objs["C"], + e: objs["E"] + ) + } + level_id += 1 + } + } + end +end diff --git a/db/migrate/92_normalize_dates.rb b/db/migrate/92_normalize_dates.rb new file mode 100644 index 00000000..3877b572 --- /dev/null +++ b/db/migrate/92_normalize_dates.rb @@ -0,0 +1,36 @@ +# Assign all initial archives the same exact date, for sorting purposes +# Also, some scores were archived long after they were done (e.g. Mishu), so we +# find the closest date by replay ID and assign it. +class NormalizeDates < ActiveRecord::Migration[5.1] + def change + # Normalize all pre-epoch dates + puts "Normalizing pre-epoch archive dates..." + Archive.where('date < ?', Archive::EPOCH).update_all(date: Archive::EPOCH) + + # Normalize late archival dates + mishu_id = CHEATERS.find{ |k, v| v.include?("Mishu") }.first + list = Archive.where(metanet_id: mishu_id) + count = list.count + + list.each_with_index do |ar, i| + # Find surrounding archived scores + prv = Archive.where(highscoreable_type: ar.highscoreable_type) + .where.not(metanet_id: mishu_id) + .where('replay_id < ?', ar.replay_id) + .order(replay_id: :desc) + .first + nxt = Archive.where(highscoreable_type: ar.highscoreable_type) + .where('replay_id > ?', ar.replay_id) + .order(replay_id: :asc) + .first + + # Compute new date + next ar.update(date: Time.now) if !nxt + next ar.update(date: Archive::EPOCH) if !prv + m = (ar.replay_id - prv.replay_id).to_f / (nxt.replay_id - prv.replay_id) + new_date = (prv.date + m * (nxt.date - prv.date)).round + print("Normalized late archival timestamp #{i + 1} / #{count}...".ljust(80, ' ') + "\r") + ar.update(date: new_date) + end + end +end diff --git a/db/migrate/93_add_metanet_id_to_userlevels.rb b/db/migrate/93_add_metanet_id_to_userlevels.rb new file mode 100644 index 00000000..3bb36001 --- /dev/null +++ b/db/migrate/93_add_metanet_id_to_userlevels.rb @@ -0,0 +1,22 @@ +# Incorporate metanet_id to userlevel scores so that we can query them like we +# do for archived vanilla scores. +class AddMetanetIdToUserlevels < ActiveRecord::Migration[5.1] + def change + add_column :userlevel_scores, :metanet_id, :integer + + max = UserlevelScore.maximum(:id) + 1 + size = 1000 + suppress_messages do + 0.step(max, size).each{ |offset| + sql <<~SQL + UPDATE userlevel_scores + INNER JOIN userlevel_players ON userlevel_players.id = userlevel_scores.player_id + SET userlevel_scores.metanet_id = userlevel_players.metanet_id + WHERE userlevel_scores.id >= #{offset} AND userlevel_scores.id < #{offset + size} + SQL + print("Updated userlevel scores %07d - %07d (%d)...\r" % [offset, offset + size, max]) + } + puts + end + end +end diff --git a/db/migrate/94_add_steam_tables.rb b/db/migrate/94_add_steam_tables.rb new file mode 100644 index 00000000..da0290dc --- /dev/null +++ b/db/migrate/94_add_steam_tables.rb @@ -0,0 +1,101 @@ +# Additional tables to store Steam data obtained via Steamworks API, mainly +# to monitor changes to N++'s app. We could use SteamDB but it doesn't provide +# an API and is less powerful than SteamKit anyway. We use it for seeding the +# inintial data though. + +require "./#{DIR_SOURCE}/api.rb" +require 'zlib' + +class AddSteamTables < ActiveRecord::Migration[5.1] + def up + create_table :steam_apps do |t| + t.integer :changenum + t.string :name + t.binary :sha, limit: 20 + t.timestamp :date + t.string :url + t.string :developer + t.boolean :free + t.boolean :os_win, default: false + t.boolean :os_linux, default: false + t.boolean :os_mac, default: false + end + + create_table :steam_branches do |t| + t.integer :app_id + t.string :name + t.text :description + t.integer :official_id + t.timestamp :created + t.timestamp :updated + t.timestamp :deleted + t.boolean :private, default: false + end + + create_table :steam_depots do |t| + t.string :name + t.integer :app_id + t.integer :max_size, limit: 8 + t.boolean :shared, default: false + t.boolean :system, default: false + t.boolean :os_win, default: false + t.boolean :os_linux, default: false + t.boolean :os_mac, default: false + t.timestamp :added + t.timestamp :removed + end + + create_table :steam_manifests do |t| + t.integer :gid, limit: 8 # For public manifests + t.binary :encrypted_gid, limit: 16 # For private manifests + t.integer :depot_id + t.string :name + t.integer :count + t.integer :size_raw, limit: 8 + t.integer :size_compressed, limit: 8 + t.timestamp :date + end + + create_table :steam_builds do |t| + t.integer :app_id + t.binary :md5 + end + + create_table :steam_build_contents do |t| + t.integer :build_id + t.integer :depot_id + t.integer :manifest_id + end + + create_table :steam_branch_versions do |t| + t.integer :branch_id + t.integer :build_id + t.integer :official_id + t.timestamp :date + end + + create_table :steam_achievements do |t| + t.integer :app_id + t.string :name + t.string :display + t.text :description + t.boolean :hidden + t.float :ratio + end + + # Seed initial data from SteamDB's history page. The rest will be fetched and kept up to date later. + SteamApp.create(id: APP_ID) + SteamDB.seed_updates(Zlib.inflate(File.read('db/steamdb_history.deflate'))) + end + + def down + drop_table :steam_apps + drop_table :steam_branches + drop_table :steam_depots + drop_table :steam_manifests + drop_table :steam_builds + drop_table :steam_build_contents + drop_table :steam_branch_versions + drop_table :steam_achievements + end +end diff --git a/db/migrate/95_create_videos.rb b/db/migrate/95_create_videos.rb new file mode 100755 index 00000000..ab4dc528 --- /dev/null +++ b/db/migrate/95_create_videos.rb @@ -0,0 +1,30 @@ +# After so many years, we're reintroducing videos into outte, this time hopefully +# with a fully automatic method to regularly fetch and parse the spreadsheet. + +class CreateVideos < ActiveRecord::Migration[5.1] + def up + create_table :videos do |t| + t.string :highscoreable_type + t.integer :highscoreable_id + t.integer :streamer_id, index: true + t.integer :challenge_id, index: true + t.string :url + end + + create_table :streamers do |t| + t.string :code + t.string :name + t.integer :player_id + t.string :twitch + t.string :youtube + end + + add_index :videos, [:highscoreable_type, :highscoreable_id] + Video.update + end + + def down + drop_table :videos + drop_table :streamers + end +end diff --git a/db/migrate/96_map_levels_to_userlevels.rb b/db/migrate/96_map_levels_to_userlevels.rb new file mode 100644 index 00000000..c0557f01 --- /dev/null +++ b/db/migrate/96_map_levels_to_userlevels.rb @@ -0,0 +1,10 @@ +# Add fields to map mappack levels to their corresponding userlevels for when +# mappacks contain already published levels and we might want to forward +# the scores instead of storing them locally. + +class MapLevelsToUserlevels < ActiveRecord::Migration[5.1] + def change + add_column :mappack_levels, :userlevel_id, :integer + add_column :mappack_levels, :forward, :boolean, default: false + end +end diff --git a/db/migrate/97_add_score_corrections.rb b/db/migrate/97_add_score_corrections.rb new file mode 100644 index 00000000..5c89a6d5 --- /dev/null +++ b/db/migrate/97_add_score_corrections.rb @@ -0,0 +1,17 @@ +# Some vanilla levels were changed after release and had gold added or removed, +# resulting in the old scores being off by a multiple of 2. We apply these +# corrections when downloading the scores, but so far we were storing them in +# the source, when we should really do it in the database. +# For mappack levels, they instead reflect changes that were done from their +# corresponding userlevel counterparts, whenever that applies. + +class AddScoreCorrections < ActiveRecord::Migration[5.1] + def change + create_table :score_corrections do |t| + t.integer :highscoreable_id + t.string :highscoreable_type + t.integer :gold + t.integer :replay_id + end + end +end diff --git a/db/migrate/98_add_palettes.rb b/db/migrate/98_add_palettes.rb new file mode 100644 index 00000000..57db3707 --- /dev/null +++ b/db/migrate/98_add_palettes.rb @@ -0,0 +1,24 @@ +# Incorporate palettes directly into the database +require "./#{DIR_SOURCE}/maps.rb" +require "./#{DIR_SOURCE}/mappacks.rb" + +class AddPalettes < ActiveRecord::Migration[5.1] + def up + create_table :palettes do |t| + t.string :name + t.string :author + t.integer :user_id + t.integer :mappack_id + t.timestamp :date + t.binary :colors, limit: 1024 + end + + add_index :palettes, :mappack_id + add_index :palettes, :user_id + Palette.seed_metanet_palettes + end + + def down + drop_table :palettes + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 5c70c11c..39a9c3e1 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -66,7 +66,9 @@ def get_names(file, starting_id) Level.where(name: completed['levels']).update_all(completed: true) end - names = get_names('names-SI.txt', 0).merge(get_names('names-S.txt', 600)).merge(get_names('names-SL.txt', 1200)) + names = get_names('db/names/names-SI.txt', 0) + .merge(get_names('db/names/names-S.txt', 600)) + .merge(get_names('db/names/names-SL.txt', 1200)) Level.update(names.keys, names.values) now = Time.now @@ -80,11 +82,13 @@ def get_names(file, starting_id) next_level_update = next_level_update.to_time next_episode_update = next_episode_update.to_time - GlobalProperty.create(key: 'current_level', value: 'SL-C-10-00') - GlobalProperty.create(key: 'current_episode', value: 'SL-C-00') - GlobalProperty.create(key: 'next_level_update', value: next_level_update.to_s) - GlobalProperty.create(key: 'next_episode_update', value: next_episode_update.to_s) - GlobalProperty.create(key: 'next_score_update', value: next_level_update.to_s) - GlobalProperty.create(key: 'saved_level_scores', value: Level.find_by(name: 'SL-C-10-00').scores.to_json(include: {player: {only: :name}})) - GlobalProperty.create(key: 'saved_episode_scores', value: Episode.find_by(name: 'SL-C-00').scores.to_json(include: {player: {only: :name}})) + GlobalProperty.find_or_create_by(key: 'current_level' , value: 'SL-C-10-00') + GlobalProperty.find_or_create_by(key: 'current_episode' , value: 'SL-C-00') + GlobalProperty.find_or_create_by(key: 'next_level_update' , value: next_level_update.to_s) + GlobalProperty.find_or_create_by(key: 'next_episode_update' , value: next_episode_update.to_s) + GlobalProperty.find_or_create_by(key: 'next_score_update' , value: next_level_update.to_s) + GlobalProperty.find_or_create_by(key: 'saved_level_scores' , value: Level.find_by(name: 'SL-C-10-00').scores.to_json(include: {player: {only: :name}})) + GlobalProperty.find_or_create_by(key: 'saved_episode_scores', value: Episode.find_by(name: 'SL-C-00').scores.to_json(include: {player: {only: :name}})) + GlobalProperty.find_or_create_by(key: "last_steam_id" , value: '76561198031272062') + end diff --git a/db/steamdb_history.deflate b/db/steamdb_history.deflate new file mode 100644 index 00000000..46912478 Binary files /dev/null and b/db/steamdb_history.deflate differ diff --git a/ext/build.sh b/ext/build.sh new file mode 100755 index 00000000..84c1a978 --- /dev/null +++ b/ext/build.sh @@ -0,0 +1,9 @@ +# Generate the Makefile +ruby extconf.rb + +# Build the C extension +LIB_DIR=../lib +mkdir -p $LIB_DIR +make +cp cinne.so $LIB_DIR/cinne.so +make clean \ No newline at end of file diff --git a/ext/extconf.rb b/ext/extconf.rb new file mode 100644 index 00000000..6f13ec84 --- /dev/null +++ b/ext/extconf.rb @@ -0,0 +1,4 @@ +# Configure the Makefile. Running this script will generate the Makefile. +require 'mkmf' +$CFLAGS << ' -Wall -O3' +create_makefile('cinne') \ No newline at end of file diff --git a/ext/main.c b/ext/main.c new file mode 100644 index 00000000..4e1d716e --- /dev/null +++ b/ext/main.c @@ -0,0 +1,25 @@ +#include "main.h" + +/* + * Entry point of the library. + * This function gets called when it gets required in Ruby + */ +void Init_cinne() { + rb_define_global_const("C_INNE", LONG2FIX(1)); + rb_define_global_function("c_stb_sha1", c_stb_sha1, 1); +} + +/* + * SHA1-encode a binary string using STB's implementation. + * This is the exact implementation used by N++, and it differs from Ruby's one + * sometimes. This way, we ensure perfect security hash computation. + */ +VALUE c_stb_sha1(VALUE self, VALUE data) { + if (!RB_TYPE_P(data, T_STRING)) + rb_raise(rb_eRuntimeError, "No data to SHA1 encode."); + unsigned char* buf = (unsigned char*)RSTRING_PTR(data); + long len = RSTRING_LEN(data); + unsigned char hash[20]; + bool success = stb_sha1(hash, buf, len); + return success ? rb_str_new((const char*)hash, 20) : Qnil; +} \ No newline at end of file diff --git a/ext/main.h b/ext/main.h new file mode 100644 index 00000000..f26df377 --- /dev/null +++ b/ext/main.h @@ -0,0 +1,16 @@ +#ifndef INNE +#define INNE + +#include +#include "ruby.h" + +/* Entry point */ +void Init_cinne(); + +/* Library functions */ +VALUE c_stb_sha1(VALUE self, VALUE data); + +/* STB functions */ +bool stb_sha1(unsigned char output[20], unsigned char *buffer, unsigned int len); + +#endif \ No newline at end of file diff --git a/ext/stb.c b/ext/stb.c new file mode 100644 index 00000000..0105e244 --- /dev/null +++ b/ext/stb.c @@ -0,0 +1,112 @@ +// This code is directly taken (and slightly modified) from the great STB: +// https://github.com/nothings/stb/ + +#include +#include + +#define stb_big32(c) (((c)[0]<<24) + (c)[1]*65536 + (c)[2]*256 + (c)[3]) + +static void stb__sha1(unsigned char *chunk, unsigned int h[5]) +{ + int i; + unsigned int a,b,c,d,e; + unsigned int w[80]; + + for (i=0; i < 16; ++i) + w[i] = stb_big32(&chunk[i*4]); + for (i=16; i < 80; ++i) { + unsigned int t; + t = w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16]; + w[i] = (t + t) | (t >> 31); + } + + a = h[0]; + b = h[1]; + c = h[2]; + d = h[3]; + e = h[4]; + + #define STB__SHA1(k,f) \ + { \ + unsigned int temp = (a << 5) + (a >> 27) + (f) + e + (k) + w[i]; \ + e = d; \ + d = c; \ + c = (b << 30) + (b >> 2); \ + b = a; \ + a = temp; \ + } + + i=0; + for (; i < 20; ++i) STB__SHA1(0x5a827999, d ^ (b & (c ^ d)) ); + for (; i < 40; ++i) STB__SHA1(0x6ed9eba1, b ^ c ^ d ); + for (; i < 60; ++i) STB__SHA1(0x8f1bbcdc, (b & c) + (d & (b ^ c)) ); + for (; i < 80; ++i) STB__SHA1(0xca62c1d6, b ^ c ^ d ); + + #undef STB__SHA1 + + h[0] += a; + h[1] += b; + h[2] += c; + h[3] += d; + h[4] += e; +} + +bool stb_sha1(unsigned char output[20], unsigned char *buffer, unsigned int len) +{ + unsigned char final_block[128]; + unsigned int end_start, final_len, j; + int i; + + unsigned int h[5]; + + h[0] = 0x67452301; + h[1] = 0xefcdab89; + h[2] = 0x98badcfe; + h[3] = 0x10325476; + h[4] = 0xc3d2e1f0; + + end_start = len & ~63; + + if (((len+9) & ~63) == end_start) { + end_start -= 64; + } + + final_len = end_start + 128; + + if (!(end_start + 128 >= len+9) || !(end_start < len || len < 64-9)) + return false; + + j = 0; + if (end_start > len) + j = (unsigned int) - (int) end_start; + + for (; end_start + j < len; ++j) + final_block[j] = buffer[end_start + j]; + final_block[j++] = 0x80; + while (j < 128-5) + final_block[j++] = 0; + + final_block[j++] = len >> 29; + final_block[j++] = len >> 21; + final_block[j++] = len >> 13; + final_block[j++] = len >> 5; + final_block[j++] = len << 3; + if (!(j == 128 && end_start + j == final_len)) + return false; + + for (j=0; j < final_len; j += 64) { + if (j+64 >= end_start+64) + stb__sha1(&final_block[j - end_start], h); + else + stb__sha1(&buffer[j], h); + } + + for (i=0; i < 5; ++i) { + output[i*4 + 0] = h[i] >> 24; + output[i*4 + 1] = h[i] >> 16; + output[i*4 + 2] = h[i] >> 8; + output[i*4 + 3] = h[i] >> 0; + } + + return true; +} \ No newline at end of file diff --git a/img/avatars/big_ninjus.png b/img/avatars/big_ninjus.png new file mode 100644 index 00000000..fdb2a697 Binary files /dev/null and b/img/avatars/big_ninjus.png differ diff --git a/img/avatars/chunky.png b/img/avatars/chunky.png new file mode 100644 index 00000000..37941bb9 Binary files /dev/null and b/img/avatars/chunky.png differ diff --git a/img/avatars/inne.png b/img/avatars/inne.png new file mode 100644 index 00000000..023360b4 Binary files /dev/null and b/img/avatars/inne.png differ diff --git a/img/avatars/molebruh.png b/img/avatars/molebruh.png new file mode 100644 index 00000000..1eb4cacc Binary files /dev/null and b/img/avatars/molebruh.png differ diff --git a/img/avatars/n_jump.png b/img/avatars/n_jump.png new file mode 100644 index 00000000..5a1f755e Binary files /dev/null and b/img/avatars/n_jump.png differ diff --git a/img/avatars/r10pez10.png b/img/avatars/r10pez10.png new file mode 100644 index 00000000..ec4730b5 Binary files /dev/null and b/img/avatars/r10pez10.png differ diff --git a/img/emoji/moleBruh.png b/img/emoji/moleBruh.png new file mode 100755 index 00000000..1eb4cacc Binary files /dev/null and b/img/emoji/moleBruh.png differ diff --git a/img/emoji/moleCool.png b/img/emoji/moleCool.png new file mode 100755 index 00000000..020b6612 Binary files /dev/null and b/img/emoji/moleCool.png differ diff --git a/img/emoji/moleGasm.png b/img/emoji/moleGasm.png new file mode 100755 index 00000000..8514e9f6 Binary files /dev/null and b/img/emoji/moleGasm.png differ diff --git a/img/emoji/moleHack.png b/img/emoji/moleHack.png new file mode 100755 index 00000000..6004a760 Binary files /dev/null and b/img/emoji/moleHack.png differ diff --git a/img/emoji/moleSwole.png b/img/emoji/moleSwole.png new file mode 100755 index 00000000..e81197ab Binary files /dev/null and b/img/emoji/moleSwole.png differ diff --git a/img/icon/discord.svg b/img/icon/discord.svg new file mode 100755 index 00000000..8361a229 --- /dev/null +++ b/img/icon/discord.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/img/icon/drone.png b/img/icon/drone.png new file mode 100755 index 00000000..d4f5438f Binary files /dev/null and b/img/icon/drone.png differ diff --git a/img/icon/drone.svg b/img/icon/drone.svg new file mode 100755 index 00000000..b0984f73 --- /dev/null +++ b/img/icon/drone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/img/icon/metanet.png b/img/icon/metanet.png new file mode 100755 index 00000000..ce742f0f Binary files /dev/null and b/img/icon/metanet.png differ diff --git a/img/icon/moleHack.ico b/img/icon/moleHack.ico new file mode 100755 index 00000000..1f4dfa0e Binary files /dev/null and b/img/icon/moleHack.ico differ diff --git a/img/icon/npp.svg b/img/icon/npp.svg new file mode 100755 index 00000000..91d11567 --- /dev/null +++ b/img/icon/npp.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/img/icon/steam.svg b/img/icon/steam.svg new file mode 100755 index 00000000..0777d82f --- /dev/null +++ b/img/icon/steam.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/img/icon/steamdb.svg b/img/icon/steamdb.svg new file mode 100755 index 00000000..85f732c4 --- /dev/null +++ b/img/icon/steamdb.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/img/object_layers/00-0_0.png b/img/object_layers/00-0_0.png new file mode 100644 index 00000000..a5d1bf95 Binary files /dev/null and b/img/object_layers/00-0_0.png differ diff --git a/img/object_layers/01-0_0.png b/img/object_layers/01-0_0.png new file mode 100644 index 00000000..a253b217 Binary files /dev/null and b/img/object_layers/01-0_0.png differ diff --git a/img/object_layers/01-0_1.png b/img/object_layers/01-0_1.png new file mode 100644 index 00000000..9e7cd54f Binary files /dev/null and b/img/object_layers/01-0_1.png differ diff --git a/img/object_layers/01-1_2.png b/img/object_layers/01-1_2.png new file mode 100644 index 00000000..04969267 Binary files /dev/null and b/img/object_layers/01-1_2.png differ diff --git a/img/object_layers/01-2_3.png b/img/object_layers/01-2_3.png new file mode 100644 index 00000000..04969267 Binary files /dev/null and b/img/object_layers/01-2_3.png differ diff --git a/img/object_layers/02-0_0.png b/img/object_layers/02-0_0.png new file mode 100644 index 00000000..3eccccec Binary files /dev/null and b/img/object_layers/02-0_0.png differ diff --git a/img/object_layers/02-0_1.png b/img/object_layers/02-0_1.png new file mode 100644 index 00000000..2055a215 Binary files /dev/null and b/img/object_layers/02-0_1.png differ diff --git a/img/object_layers/02-0_2.png b/img/object_layers/02-0_2.png new file mode 100644 index 00000000..b6c36403 Binary files /dev/null and b/img/object_layers/02-0_2.png differ diff --git a/img/object_layers/03-0_0.png b/img/object_layers/03-0_0.png new file mode 100644 index 00000000..5082bd22 Binary files /dev/null and b/img/object_layers/03-0_0.png differ diff --git a/img/object_layers/03-0_1.png b/img/object_layers/03-0_1.png new file mode 100644 index 00000000..01eb835f Binary files /dev/null and b/img/object_layers/03-0_1.png differ diff --git a/img/object_layers/03-1_0.png b/img/object_layers/03-1_0.png new file mode 100644 index 00000000..6396e73a Binary files /dev/null and b/img/object_layers/03-1_0.png differ diff --git a/img/object_layers/03-1_1.png b/img/object_layers/03-1_1.png new file mode 100644 index 00000000..12542340 Binary files /dev/null and b/img/object_layers/03-1_1.png differ diff --git a/img/object_layers/03-1_2.png b/img/object_layers/03-1_2.png new file mode 100644 index 00000000..ca877069 Binary files /dev/null and b/img/object_layers/03-1_2.png differ diff --git a/img/object_layers/03-1_3.png b/img/object_layers/03-1_3.png new file mode 100644 index 00000000..6cfdade1 Binary files /dev/null and b/img/object_layers/03-1_3.png differ diff --git a/img/object_layers/04-0_0.png b/img/object_layers/04-0_0.png new file mode 100644 index 00000000..e07326c6 Binary files /dev/null and b/img/object_layers/04-0_0.png differ diff --git a/img/object_layers/04-0_2.png b/img/object_layers/04-0_2.png new file mode 100644 index 00000000..58ae0c2e Binary files /dev/null and b/img/object_layers/04-0_2.png differ diff --git a/img/object_layers/04-0_3.png b/img/object_layers/04-0_3.png new file mode 100644 index 00000000..ad6be078 Binary files /dev/null and b/img/object_layers/04-0_3.png differ diff --git a/img/object_layers/04-1_1.png b/img/object_layers/04-1_1.png new file mode 100644 index 00000000..e07326c6 Binary files /dev/null and b/img/object_layers/04-1_1.png differ diff --git a/img/object_layers/04-1_2.png b/img/object_layers/04-1_2.png new file mode 100644 index 00000000..42b46165 Binary files /dev/null and b/img/object_layers/04-1_2.png differ diff --git a/img/object_layers/04-1_4.png b/img/object_layers/04-1_4.png new file mode 100644 index 00000000..590f2022 Binary files /dev/null and b/img/object_layers/04-1_4.png differ diff --git a/img/object_layers/05-0_0.png b/img/object_layers/05-0_0.png new file mode 100644 index 00000000..0ca6b917 Binary files /dev/null and b/img/object_layers/05-0_0.png differ diff --git a/img/object_layers/06-0_0.png b/img/object_layers/06-0_0.png new file mode 100644 index 00000000..6f1447ff Binary files /dev/null and b/img/object_layers/06-0_0.png differ diff --git a/img/object_layers/06-0_1.png b/img/object_layers/06-0_1.png new file mode 100644 index 00000000..65ee197b Binary files /dev/null and b/img/object_layers/06-0_1.png differ diff --git a/img/object_layers/07-0_0.png b/img/object_layers/07-0_0.png new file mode 100644 index 00000000..b1ea3d58 Binary files /dev/null and b/img/object_layers/07-0_0.png differ diff --git a/img/object_layers/07-0_2.png b/img/object_layers/07-0_2.png new file mode 100644 index 00000000..30f05271 Binary files /dev/null and b/img/object_layers/07-0_2.png differ diff --git a/img/object_layers/07-0_4.png b/img/object_layers/07-0_4.png new file mode 100644 index 00000000..d45817ed Binary files /dev/null and b/img/object_layers/07-0_4.png differ diff --git a/img/object_layers/07-1_1.png b/img/object_layers/07-1_1.png new file mode 100644 index 00000000..6e3ca770 Binary files /dev/null and b/img/object_layers/07-1_1.png differ diff --git a/img/object_layers/07-1_3.png b/img/object_layers/07-1_3.png new file mode 100644 index 00000000..30f05271 Binary files /dev/null and b/img/object_layers/07-1_3.png differ diff --git a/img/object_layers/07-1_5.png b/img/object_layers/07-1_5.png new file mode 100644 index 00000000..9ff47a02 Binary files /dev/null and b/img/object_layers/07-1_5.png differ diff --git a/img/object_layers/08-0_0.png b/img/object_layers/08-0_0.png new file mode 100644 index 00000000..e667b6e5 Binary files /dev/null and b/img/object_layers/08-0_0.png differ diff --git a/img/object_layers/08-0_1.png b/img/object_layers/08-0_1.png new file mode 100644 index 00000000..1e552b9b Binary files /dev/null and b/img/object_layers/08-0_1.png differ diff --git a/img/object_layers/09-0_3.png b/img/object_layers/09-0_3.png new file mode 100644 index 00000000..0b7301ed Binary files /dev/null and b/img/object_layers/09-0_3.png differ diff --git a/img/object_layers/09-0_5.png b/img/object_layers/09-0_5.png new file mode 100644 index 00000000..5dfe642e Binary files /dev/null and b/img/object_layers/09-0_5.png differ diff --git a/img/object_layers/09-1_2.png b/img/object_layers/09-1_2.png new file mode 100644 index 00000000..af3fe851 Binary files /dev/null and b/img/object_layers/09-1_2.png differ diff --git a/img/object_layers/09-1_4.png b/img/object_layers/09-1_4.png new file mode 100644 index 00000000..4f1476e3 Binary files /dev/null and b/img/object_layers/09-1_4.png differ diff --git a/img/object_layers/0A-0_0.png b/img/object_layers/0A-0_0.png new file mode 100644 index 00000000..a8633a94 Binary files /dev/null and b/img/object_layers/0A-0_0.png differ diff --git a/img/object_layers/0A-0_1.png b/img/object_layers/0A-0_1.png new file mode 100644 index 00000000..3c76c8fe Binary files /dev/null and b/img/object_layers/0A-0_1.png differ diff --git a/img/object_layers/0Ax0_0.png b/img/object_layers/0Ax0_0.png new file mode 100644 index 00000000..55b7d1ad Binary files /dev/null and b/img/object_layers/0Ax0_0.png differ diff --git a/img/object_layers/0Ax0_1.png b/img/object_layers/0Ax0_1.png new file mode 100644 index 00000000..ff27a17b Binary files /dev/null and b/img/object_layers/0Ax0_1.png differ diff --git a/img/object_layers/0B-0_0.png b/img/object_layers/0B-0_0.png new file mode 100644 index 00000000..74741605 Binary files /dev/null and b/img/object_layers/0B-0_0.png differ diff --git a/img/object_layers/0B-0_1.png b/img/object_layers/0B-0_1.png new file mode 100644 index 00000000..6e7cef25 Binary files /dev/null and b/img/object_layers/0B-0_1.png differ diff --git a/img/object_layers/0Bx0_0.png b/img/object_layers/0Bx0_0.png new file mode 100644 index 00000000..90d6d294 Binary files /dev/null and b/img/object_layers/0Bx0_0.png differ diff --git a/img/object_layers/0Bx0_1.png b/img/object_layers/0Bx0_1.png new file mode 100644 index 00000000..ad91a3e4 Binary files /dev/null and b/img/object_layers/0Bx0_1.png differ diff --git a/img/object_layers/0C-0_0.png b/img/object_layers/0C-0_0.png new file mode 100644 index 00000000..0b3b5570 Binary files /dev/null and b/img/object_layers/0C-0_0.png differ diff --git a/img/object_layers/0C-0_1.png b/img/object_layers/0C-0_1.png new file mode 100644 index 00000000..4519de52 Binary files /dev/null and b/img/object_layers/0C-0_1.png differ diff --git a/img/object_layers/0D-0_2.png b/img/object_layers/0D-0_2.png new file mode 100644 index 00000000..206e529b Binary files /dev/null and b/img/object_layers/0D-0_2.png differ diff --git a/img/object_layers/0E-0_0.png b/img/object_layers/0E-0_0.png new file mode 100644 index 00000000..b6152ed2 Binary files /dev/null and b/img/object_layers/0E-0_0.png differ diff --git a/img/object_layers/0E-0_1.png b/img/object_layers/0E-0_1.png new file mode 100644 index 00000000..bd6a2a7a Binary files /dev/null and b/img/object_layers/0E-0_1.png differ diff --git a/img/object_layers/0F-0_0.png b/img/object_layers/0F-0_0.png new file mode 100644 index 00000000..f764cc49 Binary files /dev/null and b/img/object_layers/0F-0_0.png differ diff --git a/img/object_layers/0F-0_1.png b/img/object_layers/0F-0_1.png new file mode 100644 index 00000000..25c4a4b6 Binary files /dev/null and b/img/object_layers/0F-0_1.png differ diff --git a/img/object_layers/10-0_0.png b/img/object_layers/10-0_0.png new file mode 100644 index 00000000..472c67d0 Binary files /dev/null and b/img/object_layers/10-0_0.png differ diff --git a/img/object_layers/10-0_1.png b/img/object_layers/10-0_1.png new file mode 100644 index 00000000..7011331b Binary files /dev/null and b/img/object_layers/10-0_1.png differ diff --git a/img/object_layers/11-0_0.png b/img/object_layers/11-0_0.png new file mode 100644 index 00000000..c107f67f Binary files /dev/null and b/img/object_layers/11-0_0.png differ diff --git a/img/object_layers/11-0_1.png b/img/object_layers/11-0_1.png new file mode 100644 index 00000000..1985692d Binary files /dev/null and b/img/object_layers/11-0_1.png differ diff --git a/img/object_layers/12-0_0.png b/img/object_layers/12-0_0.png new file mode 100644 index 00000000..fdb145ed Binary files /dev/null and b/img/object_layers/12-0_0.png differ diff --git a/img/object_layers/12-0_1.png b/img/object_layers/12-0_1.png new file mode 100644 index 00000000..e0175133 Binary files /dev/null and b/img/object_layers/12-0_1.png differ diff --git a/img/object_layers/13-0_0.png b/img/object_layers/13-0_0.png new file mode 100644 index 00000000..dd05adcb Binary files /dev/null and b/img/object_layers/13-0_0.png differ diff --git a/img/object_layers/13-0_1.png b/img/object_layers/13-0_1.png new file mode 100644 index 00000000..8b367fbb Binary files /dev/null and b/img/object_layers/13-0_1.png differ diff --git a/img/object_layers/14-0_0.png b/img/object_layers/14-0_0.png new file mode 100644 index 00000000..c4c3afd0 Binary files /dev/null and b/img/object_layers/14-0_0.png differ diff --git a/img/object_layers/14-0_1.png b/img/object_layers/14-0_1.png new file mode 100644 index 00000000..f6217518 Binary files /dev/null and b/img/object_layers/14-0_1.png differ diff --git a/img/object_layers/14-0_2.png b/img/object_layers/14-0_2.png new file mode 100644 index 00000000..eb88fc83 Binary files /dev/null and b/img/object_layers/14-0_2.png differ diff --git a/img/object_layers/15-0_0.png b/img/object_layers/15-0_0.png new file mode 100644 index 00000000..a253b217 Binary files /dev/null and b/img/object_layers/15-0_0.png differ diff --git a/img/object_layers/15-0_1.png b/img/object_layers/15-0_1.png new file mode 100644 index 00000000..9e7cd54f Binary files /dev/null and b/img/object_layers/15-0_1.png differ diff --git a/img/object_layers/15-1_2.png b/img/object_layers/15-1_2.png new file mode 100644 index 00000000..04969267 Binary files /dev/null and b/img/object_layers/15-1_2.png differ diff --git a/img/object_layers/15-2_3.png b/img/object_layers/15-2_3.png new file mode 100644 index 00000000..04969267 Binary files /dev/null and b/img/object_layers/15-2_3.png differ diff --git a/img/object_layers/16-0_0.png b/img/object_layers/16-0_0.png new file mode 100644 index 00000000..14f5b4ea Binary files /dev/null and b/img/object_layers/16-0_0.png differ diff --git a/img/object_layers/17-0_0.png b/img/object_layers/17-0_0.png new file mode 100644 index 00000000..f6e60f8c Binary files /dev/null and b/img/object_layers/17-0_0.png differ diff --git a/img/object_layers/17x0_0.png b/img/object_layers/17x0_0.png new file mode 100644 index 00000000..4686d4dc Binary files /dev/null and b/img/object_layers/17x0_0.png differ diff --git a/img/object_layers/18-0_0.png b/img/object_layers/18-0_0.png new file mode 100644 index 00000000..4bbc5c57 Binary files /dev/null and b/img/object_layers/18-0_0.png differ diff --git a/img/object_layers/19-0_0.png b/img/object_layers/19-0_0.png new file mode 100644 index 00000000..ddfc9f38 Binary files /dev/null and b/img/object_layers/19-0_0.png differ diff --git a/img/object_layers/19-0_1.png b/img/object_layers/19-0_1.png new file mode 100644 index 00000000..86b7caf1 Binary files /dev/null and b/img/object_layers/19-0_1.png differ diff --git a/img/object_layers/19-0_2.png b/img/object_layers/19-0_2.png new file mode 100644 index 00000000..7a214ac9 Binary files /dev/null and b/img/object_layers/19-0_2.png differ diff --git a/img/object_layers/1A-0_0.png b/img/object_layers/1A-0_0.png new file mode 100644 index 00000000..e40cfb92 Binary files /dev/null and b/img/object_layers/1A-0_0.png differ diff --git a/img/object_layers/1A-0_1.png b/img/object_layers/1A-0_1.png new file mode 100644 index 00000000..e1a38906 Binary files /dev/null and b/img/object_layers/1A-0_1.png differ diff --git a/img/object_layers/1B-0_0.png b/img/object_layers/1B-0_0.png new file mode 100644 index 00000000..0eba6a5a Binary files /dev/null and b/img/object_layers/1B-0_0.png differ diff --git a/img/object_layers/1C-0_0.png b/img/object_layers/1C-0_0.png new file mode 100644 index 00000000..88495aac Binary files /dev/null and b/img/object_layers/1C-0_0.png differ diff --git a/img/object_layers/1C-0_1.png b/img/object_layers/1C-0_1.png new file mode 100644 index 00000000..231f4b49 Binary files /dev/null and b/img/object_layers/1C-0_1.png differ diff --git a/img/object_layers/1C-0_2.png b/img/object_layers/1C-0_2.png new file mode 100644 index 00000000..73775757 Binary files /dev/null and b/img/object_layers/1C-0_2.png differ diff --git a/img/object_layers/1C-1_0.png b/img/object_layers/1C-1_0.png new file mode 100644 index 00000000..88495aac Binary files /dev/null and b/img/object_layers/1C-1_0.png differ diff --git a/img/object_layers/1C-1_1.png b/img/object_layers/1C-1_1.png new file mode 100644 index 00000000..4dcb2c62 Binary files /dev/null and b/img/object_layers/1C-1_1.png differ diff --git a/img/object_layers/1C-1_2.png b/img/object_layers/1C-1_2.png new file mode 100644 index 00000000..9dbaa8ac Binary files /dev/null and b/img/object_layers/1C-1_2.png differ diff --git a/img/object_layers/1C-2_0.png b/img/object_layers/1C-2_0.png new file mode 100644 index 00000000..de7c57ce Binary files /dev/null and b/img/object_layers/1C-2_0.png differ diff --git a/img/object_layers/1C-2_1.png b/img/object_layers/1C-2_1.png new file mode 100644 index 00000000..d8ee7b35 Binary files /dev/null and b/img/object_layers/1C-2_1.png differ diff --git a/img/palette.png b/img/palette.png new file mode 100644 index 00000000..f3fccabf Binary files /dev/null and b/img/palette.png differ diff --git a/img/tile_layers/00-0.png b/img/tile_layers/00-0.png new file mode 100644 index 00000000..aa4b5294 Binary files /dev/null and b/img/tile_layers/00-0.png differ diff --git a/img/tile_layers/01-0.png b/img/tile_layers/01-0.png new file mode 100644 index 00000000..2930125d Binary files /dev/null and b/img/tile_layers/01-0.png differ diff --git a/img/tile_layers/02-0.png b/img/tile_layers/02-0.png new file mode 100644 index 00000000..7a5b670e Binary files /dev/null and b/img/tile_layers/02-0.png differ diff --git a/img/tile_layers/02-1.png b/img/tile_layers/02-1.png new file mode 100644 index 00000000..338f7e9e Binary files /dev/null and b/img/tile_layers/02-1.png differ diff --git a/img/tile_layers/06-0.png b/img/tile_layers/06-0.png new file mode 100644 index 00000000..5449a62f Binary files /dev/null and b/img/tile_layers/06-0.png differ diff --git a/img/tile_layers/06-1.png b/img/tile_layers/06-1.png new file mode 100644 index 00000000..209f289f Binary files /dev/null and b/img/tile_layers/06-1.png differ diff --git a/img/tile_layers/0A-0.png b/img/tile_layers/0A-0.png new file mode 100644 index 00000000..8a89544d Binary files /dev/null and b/img/tile_layers/0A-0.png differ diff --git a/img/tile_layers/0A-1.png b/img/tile_layers/0A-1.png new file mode 100644 index 00000000..afb29439 Binary files /dev/null and b/img/tile_layers/0A-1.png differ diff --git a/img/tile_layers/0E-0.png b/img/tile_layers/0E-0.png new file mode 100644 index 00000000..a3609ff9 Binary files /dev/null and b/img/tile_layers/0E-0.png differ diff --git a/img/tile_layers/0E-1.png b/img/tile_layers/0E-1.png new file mode 100644 index 00000000..f402a5e2 Binary files /dev/null and b/img/tile_layers/0E-1.png differ diff --git a/img/tile_layers/12-0.png b/img/tile_layers/12-0.png new file mode 100644 index 00000000..7b1149a1 Binary files /dev/null and b/img/tile_layers/12-0.png differ diff --git a/img/tile_layers/12-1.png b/img/tile_layers/12-1.png new file mode 100644 index 00000000..3f564f74 Binary files /dev/null and b/img/tile_layers/12-1.png differ diff --git a/img/tile_layers/16-0.png b/img/tile_layers/16-0.png new file mode 100644 index 00000000..2a0a7fdc Binary files /dev/null and b/img/tile_layers/16-0.png differ diff --git a/img/tile_layers/16-1.png b/img/tile_layers/16-1.png new file mode 100644 index 00000000..178371cd Binary files /dev/null and b/img/tile_layers/16-1.png differ diff --git a/img/tile_layers/1A-0.png b/img/tile_layers/1A-0.png new file mode 100644 index 00000000..76b3aac6 Binary files /dev/null and b/img/tile_layers/1A-0.png differ diff --git a/img/tile_layers/1A-1.png b/img/tile_layers/1A-1.png new file mode 100644 index 00000000..8929e212 Binary files /dev/null and b/img/tile_layers/1A-1.png differ diff --git a/img/tile_layers/1E-0.png b/img/tile_layers/1E-0.png new file mode 100644 index 00000000..99109b10 Binary files /dev/null and b/img/tile_layers/1E-0.png differ diff --git a/img/tile_layers/1E-1.png b/img/tile_layers/1E-1.png new file mode 100644 index 00000000..61e8b3b5 Binary files /dev/null and b/img/tile_layers/1E-1.png differ diff --git a/img/transparent.png b/img/transparent.png new file mode 100644 index 00000000..6401f093 Binary files /dev/null and b/img/transparent.png differ diff --git a/inne b/inne new file mode 100755 index 00000000..9e38cdfb --- /dev/null +++ b/inne @@ -0,0 +1 @@ +ruby -W0 src/inne++.rb diff --git a/inne++.rb b/inne++.rb deleted file mode 100644 index 34bda306..00000000 --- a/inne++.rb +++ /dev/null @@ -1,287 +0,0 @@ -require 'discordrb' -require 'json' -require 'net/http' -require 'thread' -require 'yaml' -require_relative 'models.rb' -require_relative 'messages.rb' - -require 'byebug' - -DATABASE_ENV = ENV['DATABASE_ENV'] || 'development' -CONFIG = YAML.load_file('db/config.yml')[DATABASE_ENV] - -HIGHSCORE_UPDATE_FREQUENCY = 24 * 60 * 60 # daily -LEVEL_UPDATE_FREQUENCY = CONFIG['level_update_frequency'] || 24 * 60 * 60 # daily -EPISODE_UPDATE_FREQUENCY = CONFIG['episode_update_frequency'] || 7 * 24 * 60 * 60 # weekly - -def log(msg) - puts "[INFO] [#{Time.now}] #{msg}" -end - -def err(msg) - STDERR.puts "[ERROR] [#{Time.now}] #{msg}" -end - -def get_current(type) - type.find_by(name: GlobalProperty.find_by(key: "current_#{type.to_s.downcase}").value) -end - -def set_current(type, curr) - GlobalProperty.find_or_create_by(key: "current_#{type.to_s.downcase}").update(value: curr.name) -end - -def get_next(type) - ret = nil - while ret.nil? - t = type.where(completed: nil).sample - ret = t if t.scores[0].score != t.scores.last.score - end - ret.update(completed: true) - ret -end - -def get_next_update(type) - Time.parse(GlobalProperty.find_by(key: "next_#{type.to_s.downcase}_update").value) -end - -def set_next_update(type, time) - GlobalProperty.find_or_create_by(key: "next_#{type.to_s.downcase}_update").update(value: time.to_s) -end - -def get_saved_scores(type) - JSON.parse(GlobalProperty.find_by(key: "saved_#{type.to_s.downcase}_scores").value) -end - -def set_saved_scores(type, curr) - GlobalProperty.find_or_create_by(key: "saved_#{type.to_s.downcase}_scores") - .update(value: curr.scores.to_json(include: {player: {only: :name}})) -end - -def get_last_steam_id - GlobalProperty.find_or_create_by(key: "last_steam_id").value -end - -def set_last_steam_id(id) - GlobalProperty.find_or_create_by(key: "last_steam_id") - .update(value: id) -end - -def update_last_steam_id - current = User.find_by(steam_id: get_last_steam_id).id - next_user = User.where.not(steam_id: nil).where('id > ?', current).first || User.where.not(steam_id: nil).first - set_last_steam_id(next_user.steam_id) if !next_user.nil? -end - -def download_high_scores - ActiveRecord::Base.establish_connection(CONFIG) - - begin - while true - log("updating high scores...") - - Level.all.each(&:update_scores) - Episode.all.each(&:update_scores) - - log("updated high scores. updating rankings...") - - now = Time.now - [:SI, :S, :SU, :SL, :SS, :SS2].each do |tab| - [Level, Episode].each do |type| - next if type == Episode && [:SS, :SS2].include?(tab) - - [1, 5, 10, 20].each do |rank| - [true, false].each do |ties| - rankings = Player.rankings { |p| p.top_n_count(rank, type, tab, ties) } - attrs = rankings.select { |r| r[1] > 0 }.map do |r| - { - highscoreable_type: type.to_s, - rank: rank, - ties: ties, - tab: tab, - player: r[0], - count: r[1], - timestamp: now - } - end - - ActiveRecord::Base.transaction do - RankHistory.create(attrs) - end - end - end - - rankings = Player.rankings { |p| p.points(type, tab) } - attrs = rankings.select { |r| r[1] > 0 }.map do |r| - { - timestamp: now, - tab: tab, - highscoreable_type: type.to_s, - player: r[0], - points: r[1] - } - end - - ActiveRecord::Base.transaction do - PointsHistory.create(attrs) - end - - rankings = Player.rankings { |p| p.total_score(type, tab) } - attrs = rankings.select { |r| r[1] > 0 }.map do |r| - { - timestamp: now, - tab: tab, - highscoreable_type: type.to_s, - player: r[0], - score: r[1] - } - end - - ActiveRecord::Base.transaction do - TotalScoreHistory.create(attrs) - end - end - end - - next_score_update = get_next_update('score') - next_score_update += HIGHSCORE_UPDATE_FREQUENCY if next_score_update < Time.now - delay = next_score_update - Time.now - set_next_update('score', next_score_update) - - log("updated rankings, next score update in #{delay} seconds") - - sleep(delay) unless delay < 0 - end - rescue => e - err("error updating high scores: #{e}") - retry - end -end - -def send_channel_screenshot(name, caption) - name = name.gsub(/\?/, 'SS').gsub(/!/, 'SS2') - screenshot = "screenshots/#{name}.jpg" - if File.exist? screenshot - $channel.send_file(File::open(screenshot), caption: caption) - else - $channel.send_message(caption + "\nI don't have a screenshot for this one... :(") - end -end - -def send_channel_diff(level, old_scores, since) - return if level.nil? || old_scores.nil? - - diff = level.format_difference(old_scores) - $channel.send_message("Score changes on #{level.format_name} since #{since}:\n```#{diff}```") -end - -def send_channel_next(type) - log("sending next #{type.to_s.downcase}") - if $channel.nil? - err("not connected to a channel, not sending level of the day") - return false - end - - last = get_current(type) - current = get_next(type) - set_current(type, current) - - if current.nil? - err("no more #{type.to_s.downcase}") - return false - end - - last.update_scores - current.update_scores - - prefix = type == Level ? "Time" : "It's also time" - duration = type == Level ? "day" : "week" - time = type == Level ? "today" : "this week" - since = type == Level ? "yesterday" : "last week" - typename = type.to_s.downcase - - caption = "#{prefix} for a new #{typename} of the #{duration}! The #{typename} for #{time} is #{current.format_name}." - send_channel_screenshot(current.name, caption) - $channel.send_message("Current high scores:\n```#{current.format_scores}```") - - send_channel_diff(last, get_saved_scores(type), since) - set_saved_scores(type, current) - - return true -end - -def start_level_of_the_day - log("starting level of the day...") - ActiveRecord::Base.establish_connection(CONFIG) - - begin - while true - next_level_update = get_next_update(Level) - sleep(next_level_update - Time.now) unless next_level_update - Time.now < 0 - set_next_update(Level, next_level_update + LEVEL_UPDATE_FREQUENCY) - - next if !send_channel_next(Level) - log("sent next level, next update at #{get_next_update(Level).to_s}") - - next_episode_update = get_next_update(Episode) - if Time.now > next_episode_update - set_next_update(Episode, next_episode_update + EPISODE_UPDATE_FREQUENCY) - - sleep(30) # let discord catch up - - send_channel_next(Episode) - log("sent next episode, next update at #{get_next_update(Episode).to_s}") - end - end - rescue => e - err("error updating level of the day: #{e}") - retry - end -end - -def startup - ActiveRecord::Base.establish_connection(CONFIG) - - log("initialized") - log("next level update at #{get_next_update(Level).to_s}") - log("next episode update at #{get_next_update(Episode).to_s}") - log("next score update at #{get_next_update('score')}") -end - -def shutdown - log("shutting down") - $bot.stop -end - -def watchdog - sleep(3) while !$kill_threads - shutdown -end - -$bot = Discordrb::Bot.new token: CONFIG['token'], client_id: CONFIG['client_id'] -$channel = nil - -$bot.mention do |event| - respond(event) - log("mentioned by #{event.user.name}: #{event.content}") -end - -$bot.private_message do |event| - respond(event) - log("private message from #{event.user.name}: #{event.content}") -end - -puts "the bot's URL is #{$bot.invite_url}" - -startup -trap("INT") { $kill_threads = true } - -$threads = [ - Thread.new { start_level_of_the_day }, - Thread.new { download_high_scores }, -] - -$bot.run(true) - -wd = Thread.new { watchdog } -wd.join diff --git a/messages.rb b/messages.rb deleted file mode 100644 index 19685d20..00000000 --- a/messages.rb +++ /dev/null @@ -1,841 +0,0 @@ -require 'ascii_charts' -require 'gruff' -require 'zlib' -require_relative 'models.rb' - -LEVEL_PATTERN = /S[ILU]?-[ABCDEX]-[0-9][0-9]?-[0-9][0-9]?|[?!]-[ABCDE]-[0-9][0-9]?/i -EPISODE_PATTERN = /S[ILU]?-[ABCDEX]-[0-9][0-9]?/i -NAME_PATTERN = /(for|of) (.*)[\.\?]?/i - -def parse_type(msg) - (msg[/level/i] ? Level : (msg[/episode/i] ? Episode : nil)) -end - -def normalize_name(name) - name.split('-').map { |s| s[/\A[0-9]\Z/].nil? ? s : "0#{s}" }.join('-') -end - -def parse_player(msg, username) - p = msg[/for (.*)[\.\?]?/i, 1] - - if p.nil? - raise "I couldn't find a player with your username! Have you identified yourself (with '@inne++ my name is ')?" unless User.exists?(username: username) - User.find_by(username: username).player - else - raise "#{p} doesn't have any high scores! Either you misspelled the name, or they're exceptionally bad..." unless Player.exists?(name: p) - Player.find_by(name: p) - end -end - -def parse_video_author(msg) - return msg[/by (.*)[\.\?]?\Z/i, 1] -end - -def parse_challenge(msg) - return msg[/([GTOCE][+-][+-])+/] -end - -def parse_challenge_code(msg) - return msg[/([!?]+)[^-]/, 1] -end - -def parse_videos(msg) - author = parse_video_author(msg) - msg = msg.chomp(" by " + author.to_s) - highscoreable = parse_level_or_episode(msg) - challenge = parse_challenge(msg) - code = parse_challenge_code(msg) - videos = highscoreable.videos - - videos = videos.where('lower(author) = ? or lower(author_tag) = ?', author.downcase, author.downcase) unless author.nil? - videos = videos.where(challenge: challenge) unless challenge.nil? - videos = videos.where(challenge_code: code) unless code.nil? - - raise "That level doesn't have any videos!" if highscoreable.videos.empty? - raise "I couldn't find any videos matching that request! Are you looking for one of these videos?\n```#{highscoreable.videos.map(&:format_description).join("\n")}```" if videos.empty? - return videos -end - -def parse_steam_id(msg) - id = msg[/is (.*)[\.\?]?/i, 1] - raise "I couldn't figure out what your Steam ID was! You need to send a message in the format 'my steam id is '." if id.nil? - raise "Your Steam ID needs to be numerical! #{id} is not valid." if id !~ /\A\d+\Z/ - return id -end - -def parse_level_or_episode(msg) - level = msg[LEVEL_PATTERN] - episode = msg[EPISODE_PATTERN] - name = msg[NAME_PATTERN, 2] - ret = nil - - if level - ret = Level.find_by(name: normalize_name(level).upcase) - elsif episode - ret = Episode.find_by(name: normalize_name(episode).upcase) - elsif !msg[/(level of the day|lotd)/].nil? - ret = get_current(Level) - elsif !msg[/(episode of the week|eotw)/].nil? - ret = get_current(Episode) - elsif name - ret = Level.find_by("UPPER(longname) LIKE ?", name.upcase) - else - msg = "I couldn't figure out which level or episode you wanted scores for! You need to send either a level " + - "or episode ID that looks like SI-A-00-00 or SI-A-00, or a level name, using 'for .'" - raise msg - end - - raise "I couldn't find anything by that name :(" if ret.nil? - ret -end - -def parse_rank(msg) - rank = msg[/top\s*([0-9][0-9]?)/i, 1] - rank ? rank.to_i : nil -end - -def parse_bottom_rank(msg) - rank = msg[/bottom\s*([0-9][0-9]?)/i, 1] - rank ? 20 - rank.to_i : nil -end - -def parse_ranks(msg) - ranks = msg.scan(/\s+([0-9][0-9]?)/).map{ |r| r[0].to_i }.reject{ |r| r < 0 || r > 19 } - ranks.empty? ? [0] : ranks -end - -def parse_tabs(msg) - ret = [] - - ret << :SI if msg =~ /\b(intro|SI|I)\b/i - ret << :S if msg =~ /(\b|\A|\s)(N++|S|solo)(\b|\Z|\s)/i - ret << :SU if msg =~ /\b(SU|UE|U|ultimate)\b/i - ret << :SL if msg =~ /\b(legacy|SL|L)\b/i - ret << :SS if msg =~ /(\A|\s)(secret|\?)(\Z|\s)/i - ret << :SS2 if msg =~ /(\A|\s)(ultimate secret|!)(\Z|\s)/i - - ret -end - -def format_rank(rank) - rank == 1 ? "0th" : "top #{rank}" -end - -def format_type(type) - (type || 'Overall').to_s -end - -def format_ties(ties) - ties ? " with ties" : "" -end - -def format_tab(tab) - (tab == :SS2 ? '!' : (tab == :SS ? '?' : tab.to_s)) -end - -def format_tabs(tabs) - tabs.map { |t| format_tab(t) }.to_sentence + (tabs.empty? ? "" : " ") -end - -def format_time - Time.now.strftime("on %A %B %-d at %H:%M:%S (%z)") -end - -def send_top_n_count(event) - msg = event.content - player = parse_player(msg, event.user.name) - rank = parse_rank(msg) || 1 - type = parse_type(msg) - tabs = parse_tabs(msg) - ties = !!(msg =~ /ties/i) - - count = player.top_n_count(rank, type, tabs, ties) - - header = format_rank(rank) - type = format_type(type).downcase - tabs = format_tabs(tabs) - ties = format_ties(ties) - - event << "#{player.name} has #{count} #{tabs}#{type} #{header} scores#{ties}." -end - -def send_rankings(event) - msg = event.content - type = parse_type(msg) - tabs = parse_tabs(msg) - rank = parse_rank(msg) || 1 - ties = !!(msg =~ /ties/i) - - if msg =~ /average/i - if msg =~ /point/i - players = Player.where(id: Player.joins(:scores).group('players.id').having('count(highscoreable_id) > 50').pluck(:id)) - rankings = players.rankings { |p| p.average_points(type, tabs) } - header = "average point rankings " - format = "%.3f" - else - players = Player.where(id: Player.joins(:scores).group('players.id').having('count(highscoreable_id) > 50').pluck(:id)) - rankings = players.rankings { |p| p.average_points(type, tabs) }.map{|p| [p[0], 20-p[1]] } - header = "average rank rankings " - format = "%.3fth" - end - elsif msg =~ /point/i - rankings = Player.rankings { |p| p.points(type, tabs) } - header = "point rankings " - format = "%d" - elsif msg =~ /score/i - rankings = Player.rankings { |p| p.total_score(type, tabs) } - header = "score rankings " - format = "%.3f" - elsif msg =~ /tied/i - rankings = Player.rankings { |p| p.top_n_count(1, type, tabs, true) - p.top_n_count(1, type, tabs, false) } - header = "tied 0th rankings " - format = "%d" - else - rankings = Player.rankings { |p| p.top_n_count(rank, type, tabs, ties) } - rank = format_rank(rank) - ties = (ties ? "with ties " : "") - header = "#{rank} rankings #{ties}" - format = "%d" - end - - type = format_type(type) - tabs = format_tabs(tabs) - - top = rankings.take(20).select { |r| r[1] > 0 }.each_with_index.map { |r, i| "#{HighScore.format_rank(i)}: #{r[0].name} (#{format % r[1]})" } - .join("\n") - - event << "#{type} #{tabs}#{header}#{format_time}:\n```#{top}```" -end - -def send_total_score(event) - player = parse_player(event.content, event.user.name) - type = parse_type(event.content) - tabs = parse_tabs(event.content) - - score = player.total_score(type, tabs) - - type = format_type(type).downcase - tabs = format_tabs(tabs) - - event << "#{player.name}'s total #{tabs}#{type.to_s.downcase} score is #{"%.3f" % [score]}." -end - -def send_spreads(event) - msg = event.content - n = (msg[/([0-9][0-9]?)(st|nd|th)/, 1] || 1).to_i - type = parse_type(msg) || Level - tabs = parse_tabs(msg) - smallest = !!(msg =~ /smallest/) - - if n == 0 - event << "I can't show you the spread between 0th and 0th..." - return - end - - spreads = HighScore.spreads(n, type, tabs) - .sort_by { |level, spread| (smallest ? spread : -spread) } - .take(20) - .map { |s| "#{s[0]} (#{"%.3f" % [s[1]]})"} - .join("\n\t") - - spread = smallest ? "smallest" : "largest" - rank = (n == 1 ? "1st" : (n == 2 ? "2nd" : (n == 3 ? "3rd" : "#{n}th"))) - type = format_type(type).downcase - tabs = tabs.empty? ? "All " : format_tabs(tabs) - - event << "#{tabs}#{type}s with the #{spread} spread between 0th and #{rank}:\n\t#{spreads}" -end - -def send_scores(event) - msg = event.content - scores = parse_level_or_episode(msg) - scores.update_scores - - # Send immediately here - using << delays sending until after the event has been processed, - # and we want to download the scores for the episode in the background after sending since it - # takes a few seconds - event.send_message("Current high scores for #{scores.format_name}:\n```#{scores.format_scores}```") - - if scores.is_a?(Episode) - Level.where("UPPER(name) LIKE ?", scores.name.upcase + '%').each(&:update_scores) - end -end - -def send_screenshot(event) - msg = event.content - scores = parse_level_or_episode(msg) - name = scores.name.upcase.gsub(/\?/, 'SS').gsub(/!/, 'SS2') - - screenshot = "screenshots/#{name}.jpg" - - if File.exist? screenshot - event.attach_file(File::open(screenshot)) - else - event << "I don't have a screenshot for #{scores.format_name}... :(" - end -end - -def send_stats(event) - msg = event.content - player = parse_player(msg, event.user.name) - tabs = parse_tabs(msg) - counts = player.score_counts(tabs) - - histdata = counts[:levels].zip(counts[:episodes]) - .each_with_index - .map { |a, i| [i, a[0] + a[1]]} - - histogram = AsciiCharts::Cartesian.new( - histdata, - bar: true, - hide_zero: true, - max_y_vals: 15, - title: 'Score histogram' - ).draw - - totals = counts[:levels].zip(counts[:episodes]) - .each_with_index - .map { |a, i| "#{HighScore.format_rank(i)}: #{"\t%3d \t%3d\t\t %3d" % [a[0] + a[1], a[0], a[1]]}" } - .join("\n\t") - - overall = "Totals: \t%3d \t%3d\t\t %3d" % counts[:levels].zip(counts[:episodes]) - .map { |a| [a[0] + a[1], a[0], a[1]] } - .reduce([0, 0, 0]) { |sums, curr| sums.zip(curr).map { |a| a[0] + a[1] } } - - tabs = tabs.empty? ? "" : " in the #{format_tabs(tabs)} #{tabs.length == 1 ? 'tab' : 'tabs'}" - - event << "Player high score counts for #{player.name}#{tabs}:\n```\t Overall:\tLevel:\tEpisode:\n\t#{totals}\n#{overall}" - event << "#{histogram}```" -end - -def send_list(event) - msg = event.content - player = parse_player(msg, event.user.name) - type = parse_type(msg) - tabs = parse_tabs(msg) - rank = parse_rank(msg) || 20 - bott = parse_bottom_rank(msg) || 0 - ties = !!(msg =~ /ties/i) - all = player.scores_by_rank(type, tabs) - - if rank == 20 && bott == 0 && !!msg[/0th/i] - rank = 1 - bott = 0 - end - - tmpfile = File.join(Dir.tmpdir, "scores-#{player.name.delete(":")}.txt") - File::open(tmpfile, "w", crlf_newline: true) do |f| - all[bott..rank-1].each_with_index do |scores, i| - list = scores.map { |s| "#{HighScore.format_rank(s.rank)}: #{s.highscoreable.name} (#{"%.3f" % [s.score]})" } - .join("\n ") - f.write("#{bott+i}:\n ") - f.write(list) - f.write("\n") - end - end - - event.attach_file(File::open(tmpfile)) -end - -def send_community(event) - msg = event.content - tabs = parse_tabs(msg) - condition = !(tabs&[:SS, :SS2]).empty? || tabs.empty? - text = "" - - levels = Score.total_scores(Level, tabs, true) - episodes = Score.total_scores(Episode, tabs, false) - levels_no_secrets = (condition ? Score.total_scores(Level, tabs, false) : levels) - difference = levels_no_secrets[0] - 4 * 90 * episodes[1] - episodes[0] - # For every episode, we subtract the additional 90 seconds - # 4 of the 5 individual levels give you at the start to be able - # to compare an episode score with the sum of its level scores. - - text << "Total level score: #{levels[0]}\n" - text << "Total episode score: #{episodes[0]}\n" - text << (condition ? "Total level score (w/o secrets): #{levels_no_secrets[0]}\n" : "") - text << "Difference between level and episode 0ths: #{"%.3f" % [difference]}\n\n" - text << "Average level score: #{"%.3f" % [levels[0]/levels[1]]}\n" - text << "Average episode score: #{"%.3f" % [episodes[0]/episodes[1]]}\n" - text << "Average difference between level and episode 0ths: #{"%.3f" % [difference/episodes[1]]}\n" - - event << "Community's total #{format_tabs(tabs)}scores #{format_time}:\n```#{text}```" -end - -def send_maxable(event) - msg = event.content - type = parse_type(msg) || Level - tabs = parse_tabs(msg) - - ties = HighScore.ties(type, tabs) - .select { |level, tie| tie < 20 } - .sort_by { |level, tie| -tie } - .take(20) - .map { |s| "#{s[0]} (#{s[1]})" } - .join("\n") - - type = format_type(type).downcase - tabs = tabs.empty? ? "All " : format_tabs(tabs) - - event << "#{tabs}#{type}s with the most ties for 0th #{format_time}:\n```\n#{ties}```" -end - -def send_maxed(event) - msg = event.content - type = parse_type(msg) || Level - tabs = parse_tabs(msg) - - ties = HighScore.ties(type, tabs) - .select { |level, tie| tie == 20 } - .map { |s| "#{s[0]}\n" } - ties_list = ties.join - - type = format_type(type).downcase - tabs = tabs.empty? ? "All " : format_tabs(tabs) - - event << "#{tabs}potentially maxed #{type}s (with at least 20 ties for 0th) #{format_time}:\n" + - "```\n#{ties_list}```There's a total of #{ties.count{|s| s.length>1}} potentially maxed #{type}s." -end - -def send_cleanliness(event) - msg = event.content - tabs = parse_tabs(msg) - cleanest = !!msg[/cleanest/i] - episodes = tabs.empty? ? Episode.all : Episode.where(tab: tabs) - - cleanliness = episodes.map{ |e| e.cleanliness } - .sort_by{ |e| (cleanest ? e[1] : -e[1]) } - .map{ |e| "#{e[0]}:#{e[0][1] == '-' ? " " : " "}%.3f" % [e[1]] } - .take(20) - .join("\n") - - tabs = tabs.empty? ? "All " : format_tabs(tabs) - event << "#{tabs}#{cleanest ? "cleanest" : "dirtiest"} episodes #{format_time}:\n```#{cleanliness}```" -end - -def send_ownages(event) - msg = event.content - tabs = parse_tabs(msg) - ties = !!(msg =~ /ties/i) - episodes = tabs.empty? ? Episode.all : Episode.where(tab: tabs) - - ownages = episodes.map{ |e| e.ownage } - .select{ |e| e[1] == true } - .map{ |e| "#{e[0]}:#{e[0][1]=='-' ? " " : " "}#{e[2]}" } - ownages_list = ownages.join("\n") - - tabs = tabs.empty? ? "All " : format_tabs(tabs) - list = "```#{ownages_list}```" unless ownages.count == 0 - event << "#{tabs}episode ownages #{format_time}:\n#{list}There's a total of #{ownages.count} episode ownages." -end - -def send_missing(event) - msg = event.content - player = parse_player(msg, event.user.name) - type = parse_type(msg) - tabs = parse_tabs(msg) - rank = parse_rank(msg) || 20 - ties = !!(msg =~ /ties/i) - - missing = player.missing_top_ns(rank, type, tabs, ties).join("\n") - - tmpfile = File.join(Dir.tmpdir, "missing-#{player.name.delete(":")}.txt") - File::open(tmpfile, "w", crlf_newline: true) do |f| - f.write(missing) - end - - event.attach_file(File::open(tmpfile)) -end - -def send_suggestions(event) - msg = event.content - player = parse_player(msg, event.user.name) - type = parse_type(msg) || Level - tabs = parse_tabs(msg) - n = (msg[/\b[0-9][0-9]?\b/] || 10).to_i - - improvable = player.improvable_scores(type, tabs) - .sort_by { |level, gap| -gap } - .take(n) - .map { |level, gap| "#{level} (-#{"%.3f" % [gap]})" } - .join("\n") - - missing = player.missing_top_ns(20, type, tabs, false).sample(n).join("\n") - type = type.to_s.downcase - tabs = tabs.empty? ? "" : " in the #{format_tabs(tabs)} #{tabs.length == 1 ? 'tab' : 'tabs'}" - - event << "Your #{n} most improvable #{type}s#{tabs} are:\n```#{improvable}```" - event << "You're not on the board for:\n```#{missing}```" -end - -def send_level_id(event) - level = parse_level_or_episode(event.content.gsub(/level/, "")) - event << "#{level.longname} is level #{level.name}." -end - -def send_level_name(event) - level = parse_level_or_episode(event.content.gsub(/level/, "")) - raise "Episodes don't have a name!" if level.is_a?(Episode) - event << "#{level.name} is called #{level.longname}." -end - -def send_points(event) - msg = event.content - player = parse_player(msg, event.user.name) - type = parse_type(msg) - tabs = parse_tabs(msg) - points = player.points(type, tabs) - - type = format_type(type).downcase - tabs = format_tabs(tabs) - event << "#{player.name} has #{points} #{type} #{tabs}points." -end - -def send_average_points(event) - msg = event.content - player = parse_player(msg, event.user.name) - type = parse_type(msg) - tabs = parse_tabs(msg) - average = player.average_points(type, tabs) - - type = format_type(type).downcase - tabs = format_tabs(tabs) - event << "#{player.name} has #{"%.3f" % [average]} #{type} #{tabs}average points." -end - -def send_diff(event) - type = parse_type(event.content) || Level - current = get_current(type) - old_scores = get_saved_scores(type) - since = type == Level ? "yesterday" : "last week" - - diff = current.format_difference(old_scores) - event << "Score changes on #{current.format_name} since #{since}:\n```#{diff}```" -end - -def do_analysis(scores, rank) - run = scores.get_replay_info(rank) - return nil if run.nil? - - player = run['user_name'] - replay_id = run['replay_id'].to_s - score = "%.3f" % [run['score'].to_f / 1000] - analysis = scores.analyze_replay(replay_id) - gold = "%.0f" % [((run['score'].to_f / 1000) + (analysis.size.to_f / 60) - 90) / 2] - {'player' => player, 'scores' => scores.format_name, 'rank' => rank, 'score' => score, 'analysis' => analysis, 'gold' => gold} -end - -def send_analysis(event) - msg = event.content - scores = parse_level_or_episode(msg) - ranks = parse_ranks(msg) - analysis = ranks.map{ |rank| do_analysis(scores, rank) }.compact - length = analysis.map{ |a| a['analysis'].size }.max - padding = Math.log(length, 10).to_i + 1 - table_header = " " * padding + "|" + "JRL|" * analysis.size - separation = "-" * table_header.size - - # 3 types of result formatting, only 2 being used. - - raw_result = analysis.map{ |a| - a['analysis'].map{ |b| - [b % 2 == 1, b / 2 % 2 == 1, b / 4 % 2 == 1] - }.map{ |f| - frame = "" - if f[0] then frame.concat("j") end - if f[1] then frame.concat("r") end - if f[2] then frame.concat("l") end - frame - }.join(".") - }.join("\n\n") - - table_result = analysis.map{ |a| - table = a['analysis'].map{ |b| - [b % 2 == 1 ? "^" : " ", b / 2 % 2 == 1 ? ">" : " ", b / 4 % 2 == 1 ? "<" : " "].push("|") - } - while table.size < length do table.push([" ", " ", " ", "|"]) end - table.transpose - }.flatten(1) - .transpose - .each_with_index - .map{ |l, i| "%0#{padding}d|#{l.join}" % [i + 1] } - .insert(0, table_header) - .insert(1, separation) - .join("\n") - - key_result = analysis.map{ |a| - a['analysis'].map{ |f| - case f - when 0 then "-" - when 1 then "^" - when 2 then ">" - when 3 then "/" - when 4 then "<" - when 5 then "\\" - when 6 then "≤" - when 7 then "|" - else "?" - end - }.join - .scan(/.{,60}/) - .reject{ |f| f.empty? } - .each_with_index - .map{ |f, i| "%0#{padding}d #{f}" % [60*i] } - .join("\n") - }.join("\n\n") - - properties = analysis.map{ |a| - "[#{a['player']}, #{a['score']}, #{a['analysis'].size}f, rank #{a['rank']}, gold #{a['gold']}]" - }.join("\n") - explanation = "[**-** Nothing, **^** Jump, **>** Right, **<** Left, **/** Right Jump, **\\\\** Left Jump, **≤** Left Right, **|** Left Right Jump]" - header = "Replay analysis for #{scores.format_name} #{format_time}.\n#{properties}\n#{explanation}" - - result = "#{header}\n```#{key_result}```" - if result.size > 2000 then result = result[0..1993] + "...```" end - event << "#{result}" - tmpfile = File.join(Dir.tmpdir, "analysis-#{scores.name}.txt") - File::open(tmpfile, "w", crlf_newline: true) do |f| - f.write(table_result) - end - event.attach_file(File::open(tmpfile)) -end - -def send_history(event) - msg = event.content - - type = parse_type(msg) - tabs = parse_tabs(msg) - rank = parse_rank(msg) || 1 - ties = !!(msg =~ /ties/i) - - if msg =~ /point/ - history = Player.points_histories(type, tabs) - header = "point " - elsif msg =~ /score/ - history = Player.score_histories(type, tabs) - header = "score " - else - history = Player.rank_histories(rank, type, tabs, ties) - header = format_rank(rank) + format_ties(ties) - end - - history = history.sort_by { |player, data| data.max_by { |k, v| v }[1] } - .reverse - .take(30) - .select { |player, data| data.any? { |k, v| v > rank * 2 * tabs.length } } - - type = format_type(type) - tabs = format_tabs(tabs) - - graph = Gruff::Line.new(1280, 2000) - graph.title = "#{type} #{tabs}#{header}history" - graph.theme_pastel - graph.colors = [] - graph.legend_font_size = 10 - graph.marker_font_size = 10 - graph.legend_box_size = 10 - graph.line_width = 1 - graph.hide_dots = true - - step = Math.cbrt(history.count).ceil - step.times do |i| - step.times do |j| - step.times do |k| - scale = 0xFF / (step - 1) - colour = ((scale * i) << 16) + ((scale * j) << 8) + (scale * k) - graph.add_color("##{"%06x" % [colour]}") - end - end - end - - graph.colors = graph.colors.shuffle - - history.each { |player, data| graph.dataxy(player, data.keys, data.values) } - - tmpfile = File.join(Dir.tmpdir, "history.png") - graph.write(tmpfile) - - event.attach_file(File.open(tmpfile)) -end - -def identify(event) - msg = event.content - user = event.user.name - nick = msg[/my name is (.*)[\.]?$/i, 1] - - raise "I couldn't figure out who you were! You have to send a message in the form 'my name is .'" if nick.nil? - - player = Player.find_or_create_by(name: nick) - user = User.find_or_create_by(username: user) - user.player = player - user.save - - event << "Awesome! From now on you can omit your username and I'll look up scores for #{nick}." -end - -def add_steam_id(event) - msg = event.content - id = parse_steam_id(msg) - User.find_by(username: event.user.name) - .update(steam_id: id) - event << "Thanks! From now on I'll try to use your Steam ID to retrieve scores when I need to." -end - -def hello(event) - event << "Hi!" - - if $channel.nil? - $channel = event.channel - send_times(event) - end -end - -def thanks(event) - event << "You're welcome!" -end - -def send_level_time(event) - next_level = get_next_update(Level) - Time.now - next_level_hours = (next_level / (60 * 60)).to_i - next_level_minutes = (next_level / 60).to_i - (next_level / (60 * 60)).to_i * 60 - - event << "I'll post a new level of the day in #{next_level_hours} hours and #{next_level_minutes} minutes." -end - -def send_episode_time(event) - next_episode = get_next_update(Episode) - Time.now - - next_episode_days = (next_episode / (24 * 60 * 60)).to_i - next_episode_hours = (next_episode / (60 * 60)).to_i - (next_episode / (24 * 60 * 60)).to_i * 24 - - event << "I'll post a new episode of the week in #{next_episode_days} days and #{next_episode_hours} hours." -end - -def send_times(event) - send_level_time(event) - send_episode_time(event) -end - -def send_help(event) - msg = "The commands I understand are:\n" - - File.open('README.md').read.each_line do |line| - line = line.gsub("\n", "") - if line == " " - event.send_message(msg) - msg = "Commands continued...\n" - else - msg += "\n**#{line.gsub(/^### /, "")}**\n" if line =~ /^### / - msg += " *#{line.gsub(/^- /, "").gsub(/\*/, "")}*\n" if line =~ /^- \*/ - end - end - - event.send_message(msg) - - event << "In any of these commands, if you see '', replace that with either a level/episode ID (eg. SI-A-00-00) or a level name (eg. supercomplexity)" - event << "If you see '', you can replace that with any combination of SI/intro, S/N++, SU/ultimate, SL/legacy, ?/secret, and !/ultimate secret, or you can leave it off for overall totals." - event << "If the command is related to a specific player, you can specify it by ending your message with 'for '. Otherwise, I'll use the one you specified earlier." -end - -def send_level(event) - event << "The current level of the day is #{get_current(Level).format_name}." -end - -def send_episode(event) - event << "The current episode of the week is #{get_current(Episode).format_name}." -end - -def dump(event) - log("current level/episode: #{get_current(Level).format_name}, #{get_current(Episode).format_name}") unless get_current(Level).nil? - log("next updates: scores #{get_next_update('score')}, level #{get_next_update(Level)}, episode #{get_next_update(Episode)}") - - event << "I dumped some things to the log for you to look at." -end - -def send_videos(event) - videos = parse_videos(event.content) - - # If we have more than one video, we probably shouldn't spam the channel too hard... - # so we'll make people be more specific unless we can narrow it down. - if videos.length == 1 - event << videos[0].url - return - end - - descriptions = videos.map(&:format_description).join("\n") - default = videos.where(challenge: ["G++", "?!"]) - - # If we don't have a specific challenge to look up, we default to sending - # one without challenges - if default.length == 1 - # Send immediately, so the video link shows above the additional videos - event.send_message(default[0].url) - event << "\nI have some challenge videos for this level as well! You can ask for them by being more specific about challenges and authors, by saying ' video for ' or 'video for by ':\n```#{descriptions}```" - return - end - - event << "You're going to have to be more specific! I know about the following videos for this level:\n```#{descriptions}```" -end - -# TODO set level of the day on startup -def respond(event) - msg = event.content - - # strip off the @inne++ mention, if present - msg.sub!(/\A<@[0-9]*> */, '') - - # match exactly "lotd" or "eotw", regardless of capitalization or leading/trailing whitespace - if msg =~ /\A\s*lotd\s*\Z/i - send_level(event) - return - elsif msg =~ /\A\s*eotw\s*\Z/i - send_episode(event) - return - end - - # exclusively global methods, this conditional avoids the problem stated in the comment below - if !msg[NAME_PATTERN, 2] - send_rankings(event) if msg =~ /rank/i && msg !~ /history/i - send_history(event) if msg =~ /history/i && msg !~ /rank/i - send_spreads(event) if msg =~ /spread/i - send_diff(event) if msg =~ /diff/i - send_community(event) if msg =~ /community/i - send_maxable(event) if msg =~ /maxable/i - send_maxed(event) if msg =~ /maxed/i - send_cleanliness(event) if msg =~ /cleanest/i || msg =~ /dirtiest/i - send_ownages(event) if msg =~ /ownage/i - send_help(event) if msg =~ /\bhelp\b/i || msg =~ /\bcommands\b/i - end - - # on this methods, we will exclude a few problematic words that appear - # in some level names which would accidentally trigger them - hello(event) if msg =~ /\bhello\b/i || msg =~ /\bhi\b/i - thanks(event) if msg =~ /\bthank you\b/i || msg =~ /\bthanks\b/i - dump(event) if msg =~ /dump/i - send_level(event) if msg =~ /what.*(level|lotd)/i - send_episode(event) if msg =~ /what.*(episode|eotw)/i - send_episode_time(event) if msg =~ /when.*next.*(episode|eotw)/i - send_level_time(event) if msg =~ /when.*next.*(level|lotd)/i - send_points(event) if msg =~ /\bpoints/i && msg !~ /history/i && msg !~ /rank/i && msg !~ /average/i && msg !~ /floating/i && msg !~ /legrange/i - send_average_points(event) if msg =~ /\bpoints/i && msg !~ /history/i && msg !~ /rank/i && msg =~ /average/i && msg !~ /floating/i && msg !~ /legrange/i - send_scores(event) if msg =~ /scores/i && !!msg[NAME_PATTERN, 2] - send_total_score(event) if msg =~ /total\b/i && msg !~ /history/i && msg !~ /rank/i - send_top_n_count(event) if msg =~ /how many/i - send_stats(event) if msg =~ /\bstat/i && msg !~ /generator/i && msg !~ /hooligan/i && msg !~ /space station/i - send_screenshot(event) if msg =~ /screenshot/i - send_suggestions(event) if msg =~ /worst/i && msg !~ /nightmare/i - send_list(event) if msg =~ /\blist\b/i && msg !~ /of inappropriate words/i - send_missing(event) if msg =~ /missing/i - send_level_name(event) if msg =~ /\blevel name\b/i - send_level_id(event) if msg =~ /\blevel id\b/i - send_analysis(event) if msg =~ /analysis/i - identify(event) if msg =~ /my name is/i - add_steam_id(event) if msg =~ /my steam id is/i - send_videos(event) if msg =~ /\bvideo\b/i || msg =~ /\bmovie\b/i - -rescue RuntimeError => e - # Exceptions raised in here are user error, indicating that we couldn't - # figure out what they were asking for, so send the error message out - # to the channel - event << e -end diff --git a/models.rb b/models.rb deleted file mode 100644 index ec30614f..00000000 --- a/models.rb +++ /dev/null @@ -1,382 +0,0 @@ -# coding: utf-8 -require 'active_record' -require 'net/http' - -IGNORED_PLAYERS = [ - "Kronogenics", - "BlueIsTrue", - "fiordhraoi", - "cheeseburgur101", - "Jey", - "jungletek", - "Hedgy", - "ᕈᘎᑕᒎᗩn ᙡiᗴᒪḰi", - "Venom", - "EpicGamer10075", - "Altii", - "Puςe", - "Floof The Goof", -] - -module HighScore - def self.format_rank(rank) - "#{rank < 10 ? '0' : ''}#{rank}" - end - - def self.spreads(n, type, tabs) - spreads = {} - scores = tabs.empty? ? type.all : type.where(tab: tabs) - - scores.each do |elem| - spread = elem.spread(n) - if !spread.nil? - spreads[elem.name] = spread - end - end - - spreads - end - - def self.ties(type, tabs) - ties = {} - scores = tabs.empty? ? type.all : type.where(tab: tabs) - - scores.each do |elem| - tie_count = elem.tie_count - if !tie_count.nil? && tie_count > 3 - ties[elem.name] = tie_count - end - end - - ties - end - - def scores_uri(steam_id) - URI("https://dojo.nplusplus.ninja/prod/steam/get_scores?steam_id=#{steam_id}&steam_auth=&#{self.class.to_s.downcase}_id=#{self.id.to_s}") - end - - def replay_uri(steam_id, replay_id) - URI("https://dojo.nplusplus.ninja/prod/steam/get_replay?steam_id=#{steam_id}&steam_auth=&replay_id=#{replay_id}") - end - - def get_scores - initial_id = get_last_steam_id - response = Net::HTTP.get(scores_uri(initial_id)) - while response == '-1337' - update_last_steam_id - break if get_last_steam_id == initial_id - response = Net::HTTP.get(scores_uri(get_last_steam_id)) - end - return nil if response == '-1337' - correct_ties(JSON.parse(response)['scores']) - rescue => e - err("error getting scores: #{e}") - retry - end - - def get_replay(replay_id) - initial_id = get_last_steam_id - response = Net::HTTP.get(replay_uri(initial_id, replay_id)) - while response == '-1337' - update_last_steam_id - break if get_last_steam_id == initial_id - response = Net::HTTP.get(replay_uri(get_last_steam_id, replay_id)) - end - return nil if response == '-1337' - response - rescue => e - err("error getting replay: #{e}") - retry - end - - def save_scores(updated) - updated = updated.select { |score| !IGNORED_PLAYERS.include?(score['user_name']) }.uniq { |score| score['user_name'] } - - ActiveRecord::Base.transaction do - updated.each_with_index do |score, i| - scores.find_or_create_by(rank: i) - .update( - score: score['score'] / 1000.0, - player: Player.find_or_create_by(name: score['user_name']), - tied_rank: updated.find_index { |s| s['score'] == score['score'] } - ) - end - end - end - - def update_scores - updated = get_scores - - if updated.nil? - # TODO make this use err() - STDERR.puts "[WARNING] [#{Time.now}] failed to retrieve scores from #{scores_uri(get_last_steam_id)}" - return - end - - save_scores(updated) - end - - def get_replay_info(rank) - updated = get_scores - - if updated.nil? - # TODO make this use err() - STDERR.puts "[WARNING] [#{Time.now}] failed to retrieve replay info from #{scores_uri(get_last_steam_id)}" - return - end - - updated.select { |score| !IGNORED_PLAYERS.include?(score['user_name']) }.uniq { |score| score['user_name'] }[rank] - end - - # Replay data format: Unknown (4b), replay ID (4b), level ID (4b), user ID (4b) and demo data compressed with Zlib. - # Demo data format: Unknown (1b), data length (4b), unknown (4b), frame count (4b), level ID (4b), unknown (13b) and actual demo. - # Demo format: Each byte is one frame, first bit is jump, second is right and third is left. Also, suicide is 0C. - # Note: The first frame is fictional and must be ignored. - def analyze_replay(replay_id) - replay = get_replay(replay_id) - demo = Zlib::Inflate.inflate(replay[16..-1])[30..-1] - analysis = demo.unpack('H*')[0].scan(/../).map{ |b| b.to_i }[1..-1] - end - - def correct_ties(score_hash) - score_hash.sort_by{ |s| [-s['score'], s['replay_id']] } - end - - def spread(n) - scores.find_by(rank: n).spread unless !scores.exists?(rank: n) - end - - def tie_count - scores.take_while{ |s| s.tie }.count - end - - def format_scores - scores.map(&:format).join("\n") - end - - def difference(old) - scores.map do |score| - oldscore = old.find { |o| o['player']['name'] == score.player.name } - change = nil - - if oldscore - change = {rank: oldscore['rank'] - score.rank, score: score.score - oldscore['score']} - end - - {score: score, change: change} - end - end - - def format_difference(old) - difference(old).map { |o| - c = o[:change] - diff = c ? "#{"++-"[c[:rank] <=> 0]}#{c[:rank].abs}, +#{"%.3f" % [c[:score]]}" : "new" - "#{o[:score].format} (#{diff})" - }.join("\n") - end -end - -class Level < ActiveRecord::Base - include HighScore - has_many :scores, as: :highscoreable - has_many :videos, as: :highscoreable - enum tab: [:SI, :S, :SU, :SL, :SS, :SS2] - - def format_name - "#{longname} (#{name})" - end -end - -class Episode < ActiveRecord::Base - include HighScore - has_many :scores, as: :highscoreable - has_many :videos, as: :highscoreable - enum tab: [:SI, :S, :SU, :SL, :SS, :SS2] - - def format_name - "#{name}" - end - - def cleanliness - [name, Level.where("UPPER(name) LIKE ?", name.upcase + '%').map{ |l| l.scores[0].score }.sum - scores[0].score - 360] - end - - def ownage - owner = scores[0].player.name - [name, Level.where("UPPER(name) LIKE ?", name.upcase + '%').map{ |l| l.scores[0].player.name == owner }.count(true) == 5, owner] - end -end - -class Score < ActiveRecord::Base - belongs_to :player - belongs_to :highscoreable, polymorphic: true - belongs_to :level, -> { where(scores: {highscoreable_type: 'Level'}) }, foreign_key: 'highscoreable_id' - belongs_to :episode, -> { where(scores: {highscoreable_type: 'Episode'}) }, foreign_key: 'highscoreable_id' - - def self.total_scores(type, tabs, secrets) - tabs = (tabs.empty? ? [:SI, :S, :SL, :SU, :SS, :SS2] : tabs) - tabs = (secrets ? tabs : tabs - [:SS, :SS2]) - query = self.where(rank: 0, highscoreable_type: type.to_s) - result = (query.includes(:level).where(levels: {tab: tabs}) + query.includes(:episode).where(episodes: {tab: tabs})).map{ |s| s.score } - [result.sum, result.count] - end - - def spread - highscoreable.scores.find_by(rank: 0).score - score - end - - def tie - highscoreable.scores.find_by(rank: 0).score == score - end - - def format - "#{HighScore.format_rank(rank)}: #{player.name} (#{"%.3f" % [score]})" - end -end - -class Player < ActiveRecord::Base - has_many :scores - has_many :rank_histories - has_many :points_histories - has_many :total_score_histories - has_one :user - - def self.rankings(&block) - players = Player.includes(:scores).all - - players.map { |p| [p, yield(p)] } - .sort_by { |a| -a[1] } - end - - def self.histories(type, attrs, column) - hist = type.where(attrs).includes(:player) - - ret = Hash.new { |h, k| h[k] = Hash.new { |h, k| h[k] = 0 } } - - hist.each do |h| - ret[h.player.name][h.timestamp] += h.send(column) - end - - ret - end - - def self.rank_histories(rank, type, tabs, ties) - attrs = {rank: rank, ties: ties} - attrs[:highscoreable_type] = type.to_s if type - attrs[:tab] = tabs if !tabs.empty? - - self.histories(RankHistory, attrs, :count) - end - - def self.score_histories(type, tabs) - attrs = {} - attrs[:highscoreable_type] = type.to_s if type - attrs[:tab] = tabs if !tabs.empty? - - self.histories(TotalScoreHistory, attrs, :score) - end - - def self.points_histories(type, tabs) - attrs = {} - attrs[:highscoreable_type] = type.to_s if type - attrs[:tab] = tabs if !tabs.empty? - - self.histories(PointsHistory, attrs, :points) - end - - def scores_by_type_and_tabs(type, tabs) - ret = type ? scores.where(highscoreable_type: type.to_s) : scores - ret = tabs.empty? ? ret : ret.includes(:level).where(levels: {tab: tabs}) + ret.includes(:episode).where(episodes: {tab: tabs}) - ret - end - - def top_ns(n, type, tabs, ties) - scores_by_type_and_tabs(type, tabs).select do |s| - (ties ? s.tied_rank : s.rank) < n - end - end - - def top_n_count(n, type, tabs, ties) - top_ns(n, type, tabs, ties).count - end - - def scores_by_rank(type, tabs) - ret = Array.new(20, []) - scores_by_type_and_tabs(type, tabs).group_by(&:rank).sort_by(&:first).each { |rank, scores| ret[rank] = scores } - ret - end - - def score_counts(tabs) - { - levels: scores_by_rank(Level, tabs).map(&:length).map(&:to_i), - episodes: scores_by_rank(Episode, tabs).map(&:length).map(&:to_i) - } - end - - def missing_top_ns(n, type, tabs, ties) - levels = top_ns(n, type, tabs, ties).map { |s| s.highscoreable.name } - - if type - type.where(tab: tabs).where.not(name: levels).pluck(:name) - else - Level.where(tab: tabs).where.not(name: levels).pluck(:name) + Episode.where(tab: tabs).where.not(name: levels).pluck(:name) - end - end - - def improvable_scores(type, tabs) - improvable = {} - scores_by_type_and_tabs(type, tabs).each { |s| improvable[s.highscoreable.name] = s.spread } - improvable - end - - def points(type, tabs) - scores_by_type_and_tabs(type, tabs).pluck(:rank).map { |rank| 20 - rank }.reduce(0, :+) - end - - def average_points(type, tabs) - scores = scores_by_type_and_tabs(type, tabs).pluck(:rank).map { |rank| 20 - rank } - scores.length == 0 ? 0 : scores.reduce(0, :+).to_f / scores.length - end - - def total_score(type, tabs) - scores_by_type_and_tabs(type, tabs).pluck(:score).reduce(0, :+) - end -end - -class User < ActiveRecord::Base - belongs_to :player -end - -class GlobalProperty < ActiveRecord::Base -end - -class RankHistory < ActiveRecord::Base - belongs_to :player - enum tab: [:SI, :S, :SU, :SL, :SS, :SS2] -end - -class PointsHistory < ActiveRecord::Base - belongs_to :player - enum tab: [:SI, :S, :SU, :SL, :SS, :SS2] -end - -class TotalScoreHistory < ActiveRecord::Base - belongs_to :player - enum tab: [:SI, :S, :SU, :SL, :SS, :SS2] -end - -class Video < ActiveRecord::Base - belongs_to :highscoreable, polymorphic: true - - def format_challenge - return (challenge == "G++" || challenge == "?!") ? challenge : "#{challenge} (#{challenge_code})" - end - - def format_author - return "#{author} (#{author_tag})" - end - - def format_description - "#{format_challenge} by #{format_author}" - end -end diff --git a/names-S.txt b/names-S.txt deleted file mode 100644 index 7cd305bb..00000000 --- a/names-S.txt +++ /dev/null @@ -1,600 +0,0 @@ -flateau -a light launch -swizzle -corridory -cyberdemon retirement community -gorge -precision ground handling -pixelightning -zigzaggurat -disallowed -the amount of jump -another one of these contraptions -chain of events -conspicuous consumption -cheap thrills -self restraint -sleestack -balcon -counterclockwise heart -stripes -bridge under construction -angler -kinetic joy -box slighter -clairvoyage -a brief history of amazing letdowns -hatching chambers -hella high water -delay distortion -shadow play -simple mountain hike -rectilinear drips -shortcutlets -stroopwafel -containment area -opportunity costs -circula -trivium reactor -the ceiling is bendin -a strictly optional haunting -your basic triangle laser trap -the barricades -vector poem -go fish -one got loose -alex austin's metaphor of life -cave flier -voices from the lake -zingy zaps -third strike -sky school -it's full of stars -the year is 199X -ocean spray -squarangulation -atmosphere recycler -product placement -carouse hell -plaftormer -deconstruction set -fractality -electroencephalograffiti -galorb -hill complex -hallway monitor -fract'd -creeping closer -fancy footwork -scaffolded -alien wreck -metallic tang -mild rumpus -deep within the mine mines -slim odds -blokus'd -galaxeasy -strolly -deathtrap? -monoceros -trungie -prickly pete -mine-altering substance -completionist's dilemma -roguish -hella quids -both sides of the brobe -floored -blorbity fjords -favourite turret -barriers to entry -queasy -two times -command and conquer -in the heat of the ninja -bloodjet eye -clusterfun -the repository -defiance -septentrion -stimulation of chaos -trimounds -cyber decaf -xercise -of the thirty-six strategems, fleeing is best -the lunatic device -introductory jumpventure -highly irregular -brain sliva -pot of gold -laser-assisted haircut -as you dare -harried hallway -wabi-sabi -pokey gaps -gibsonesque scenery -armor shards -acen -electronic eyes -expedient -buffer underrun -leap year -spaces -slopular -the griddler -central chamber -emptriness -grey hill zone -prosumer -knurled -gamalgamated -hoverflysby -basic shapes -go -roguesque -heart-shaped blocks -appendicide -your textbook swizzle hallway -anklebiter -the modular -infinite edge -descen -peaked -rhompus -portals -bullsh++ mountain -proof of shove-cept -solvalou -framed -shodan -tenfold crossing -a brief lesson on not jumping (except maybe at the very end (if you know what you're doing)) -cyber lark -deliberate choices aplenty -historicity -semi-addled -base jump -loopers -bit-rot landscape -cutting corners -die area -meldac -insert blasstacre -local space -avoid direct eye contact -alert security -shoot it -on shakey ground -branch misprediction -tindersticks -the mile-high jellyfish -now my timeline becomes immaculate -structural integrity -mid-spectrum warrior -all over the place -one who suttns -mine mounds -slasher -feats of downward strength -gravity -factory ruins -mechanic dimensional straight line -crawlspace -jump shots -recondite -the legend of the gold on that one level -the other tick -hide and seek -salmon run -beware the leap: hard -verticulous -conumber -harderer -the light at the end of the tunnel -bocks -super time-forced -barrys -err ducts -blockchain -ascent into madness -temptation temple -the machine will tell us so -race to the bottom -logiciel -process of elimination -there but for the grace of graboids -futuristhmus -leaps and bounds -the wobblies part 1 -hunting grounds -multiple redundancy self-destruct sequence activation protocol -negative land -confetti-ville -mental leaps -good grid dance -finite resources -in utero -sun of a b -a circuitous route -spun -the conveyance -roomy -out of the blue++ -a series of interesting choices -pyrite -schnorgled -don't look down -cyber-cave -dashing -money pit -middlemarch -far fall, eh? -architectural relief -bust an indeterminate number of moves -zusty mordant -photongle -easy divisors -capsule hotels -wading pool -keep them moving in your mind -anti-intrusion electron space -a fabulous crime -freefall fort -a rather curious horizontal mechanism -laser time -everything seems impossible when you haven't done it yet -narbacular drop -contained risk -gridnik -inverted gears -a journey -winds of change -directional bias -datacreche -your garden-variety quad gold hazard -introjection -clamber -brambly -experiencing the world through your own volition -ruckazoid -freeformed -funky dynamite -mathew kumar's capitalist death machine -nu age cloud -frisson -level designed to thrizzle -peek-a-boom -pcell -don't -beamer -tangled up -sinusoidal -worm drive -delimiters -world's two greatest monsters -hidden in plain sight -weaving -it followed me home -phillips -approachment -zone of possible agreement -one is all you need -sixty seven point five degrees -crisscrossy -sousveillance -funnely enough -diamond ladder -segmentation fault -mega-pokeys -quad brobe -endgame -shots fired -coffin nails -light timer -max float -probability 0.0000001 -ninjabilities -hardester -hinted -time pressure -degreelessness -circular logic -stock depletion -temptation -containment units -the cyborging chamber -mt. err-ebus -rebunk -a little too busy at the drone factory -the squizzler -poor impulse control -lowrez cavern -overhead -running out of chances -the overseer -human techniques for earning glory -interlaced with... -the still vermillion -undead burg -baby lorg -bouken! -lazorbs -mostly harmful -some like it not -running down that hill -infiltration system -diactites and stalagonals -bitcrushed seafoam -subtleties -overlap -toe-tapper -twisted sprint -spiral gyro -jagabees -rumpled slitscan -the algorithm -y'all got that videoball? -sweeps -space station -arc minutes -inside the autonomous sensory meridian response reactor -the legend of column b -moving platform -reload the program of misery -machine aesthetic -strata gem -blocktoberfest -that backward-looking mood -squeeze play -nail biter -a thoroughly permuted scenario -inelegant -terrain -random walks of kindness -the mobile crowd -gold rush -steps -boxen -vaunt -acroblat -lazorb drone -simplex -skeletal fortress -tactical freakout -fantastically contrapted -polyrhythmic pit -platten freak -glide -betting the hausen -platformonics -fear of repeating yourself -detunized gravity -shooting gallery -runcible -forgone accumulator -symbolic act -ripple mountain -speedrun dream -the gree -neo tokyo -android architect -intelligent falling -an irregular path -unsteady -surprisingly unbounded awkwardness potential -the lack of sound and the fury -protuberances -float max -ring around the rosenthal zero-point energy device -machine precision -devious shoves -blocked -at the bottom of the well -hot on the heels -problematic cycle -supercomplexity -smattered -reality leaves a lot to the imagination -future unfolding -hellway -opening gambit -tentacly increnect -midpoint algorithm -a wrinkle in time -orbital habitat -unstructured play -squarez -semi-menacing -fallen angel industrial park -lose the tail -chock several blocks -curl noise -in an upwardly fashion -think about it -press your luck -incompatible override -of mice and men and evil ninjas -running out of headroom -hypnagogic -river hopping -bitrate reduction -riding the rail -world's greatest evil robotic cookie monster -tresher -leapfrog -feral concrete -tantalization -clever girl -reduced degrees of freedom -block party -house of ten axis -abandoned tower cavern fragment -three increments from hooligan status -semantic loops -target lock -manifest -x-over -lurid details -bouncy, trouncy, flouncy, pouncy fun -vorb roblon -cyber dungeon quest -perp-plexed -retracer -hexagonal honey trap -multipular staged operation -possibly impossible -medium-sized shop of horrors -when not to jump -cinnabomb -master exploder -abject terror -the other other tick -map fragment -mission understatement -upwardly mobile -meditations in an emergency -the devil's plaything -one too many dramatic flourishes -orthogonal axes -superliminal++ -mikado -recurrance relation -raktor -side-channel attack -one-button game -earthdate 2264 -this dome is our home -clockwork zigzag -manic miner -a mild stroboscopic effect -a mind is a terrible thing -choking hazard -laser mission -deathball surprise -an immaculately mined moon -the marinara trench -ono-sendai -sufficient ascent rapidity -fragments of a shattered direction -irritable level syndrome -away in a mangler -eddy -let's launch over it! -plandings -one more spin around the light -reaper madness -oblate spheroid, ob-la-da -an excursion -comb filter -rigid internal logic -nonplusplussed -fire sticks -thinking on the balls of your feet -interstitial -bewilderness -your vision is a bunch of illusions that your brain is constantly cooking up -ether drift theory -galactic sigil -it's adventure time -dragged forward by time -too much is almost enough -stop -quadrophobia -saga -sparse set -following -quit pro quo -microblindsided -scanlines and diamond mines -deadly premonitions -awhoops -a rough approximation of laser-drums -interloper -cloned ninja assassin selection process -vertex pain dispenser -leapin' laser lids -classified control study 985.3b -a study in scarless -the brute squad -laser windows -pace yourself -angledance -all hail arachnid -losing your cool -complexity reducer -escalation -rife with metaphor -convergence -self-defeating -fotoplastikon -straight laser -careful consideration -bit depths -cutting it close -setagaya waverunner -damballah -MUFG -fear less -occam's laser -lance micron -method conductor -overdrive -the emboldening -sneaky toggle fortress -the river -time barons of hell -unlocky -photon flux -spring sprang -hop-scorch -ai ant colony -bamboo training forest -spacing -puzzle palace -totally bleeped -operator overload -eye of the ninja -sparse matrix solver -barrel shoot -syncretic -suboptimal panopticon -victory lapse -slaughterhouse three -escalating delirium -biscuity boyle -squibs -killsphere -spin quantum number -vortex of plans -ancient patrol route -nothing is impossible -upon the deep -the fine art of biting off more than you can chew -you want me to what?! -afternoon at the complex -masterclass -the great filter -riding high -blood monkey -curiously recurring template pattern -mid-air marathon -shoot -data hound -the cave near infinity point -panoplea -slathered -reset -a deceptively strong case for checkpoints -vertiginous -a series of unfortunate events -escape is at hand -memory insufficient -epiphany continuum -strive harder -chase ace -braindance -deathballroom -automatical hard -skipping stone -terrrors -championship lode runner diff --git a/names-SI.txt b/names-SI.txt deleted file mode 100644 index dab07e36..00000000 --- a/names-SI.txt +++ /dev/null @@ -1,125 +0,0 @@ -the basics -walljumptroduction -intro to accepting your limitations? -wall-to-wall -basics redux -toggley tutor -both flavours of ramp jumping (and the control thereof) -bounce -the all-powerful golden rainbow of upwards momentum -a hint of what's to come -press the suicide button to retry -balljump -game shover -thwumpy friends -mine field challenge -practice makes perp-fect -launching off of bounceblocks is possible if you time it correctly -they'll chase ya -sideways walljump test -laser training -MEGAJUMP -laser braces -the art of descent part the fourth -practical application of theory -toggley terminus -regarding speed and jump control -floor-to-wall vs ramp-to-wall 'double jumping' -hitting your head -wall-to-wall-to-wall-to-wall -warmup track -deadly friend -the art of descent part the first -on the subject of the navigation of gaps -launchers -rocketoggle room -laser-wise -the art of descent part the second -temporary platform -going up -a taste of evil -multimode phase 0 -multimode phase 1 -multimode phase 2 -multimode phase 3 -multimodal -circular momentum -encyclopedia of fundamentals vol xxi: the mega-perp -parkour park prototype -timing tutor -tutorial mid-boss -double-jumping, of a sort -bouncers -to jump, or not to jump -the fine art of not pressing left/right until after pressing and holding the jump button -freerunning room -learning process -minefield simulator -wall-hugging -boosting -greed can be dangerous -doors galore -on a long enough timeline, the survival rate for everyone drops to zero -velocity multiplication device -mind-expansion -dexterity calibration apparatus II -temporary wall -the art of descent part the third -evil echoes -onesided walljump orientation seminar -laser struggle -all aboard! -optionality -sometimes the best jump is to not jump -the room 2 -miney movement masterclass -rectilinear rooms -inertia control -traditional landscape -skydiver -learning to climb -fine-a++ motor skills -keep it moving -launch guidelines -bounceblock bridge -speed limiters -jumping off ramps can launch you -getting to know rockets -booster stage -chained temptation -the lookers -dexterity calibration apparatus III -live fire training exercise -tricky toggles -temporary test -onesided walljump exit exam -thrice will suffice -air control chamber -ingenuity opportunity -walljumptroduction redux -a taste of real life -the many uses of ramps -various amounts of jump -the two methods of ramp jumping and the control thereof (part the second) -tiny taps -self-directed -the room -throwing caution to the wind -dexterity calibration apparatus I -who needs floors anyway -bullet william -air control towers -oscillaser -ride or die -vertical momentum test -starting to get the hang of it -rubber room -good and bad doors -introduction to ad-libbing -movement under fire -hangtime -the impossible is possible -graduate degree in walljumping -quote-unquote triple-jumping -deadly didactic device -puzzle platformer diff --git a/names-SL.txt b/names-SL.txt deleted file mode 100644 index 0c77e3f8..00000000 --- a/names-SL.txt +++ /dev/null @@ -1,600 +0,0 @@ -arc -take the easy way out -orbital -what the -all about thwumps -rooted -underground storage facility -ups and downs -the machine -suicidal lust for gold -the thing that only eats ninjas -honey bomb -acrophobe -build that momentum! -hairpin turns -megamine -rotor -cutting in -simplicity -high water line -stumbleine -slick -mugen -trance gate -superliminal futura -learning to fly -come back zinc! -suspension bridge -bullish on gold -tighten up tighter -hanging gardens -slipgate complex -figure ocho -up and around -fool's gold -crop rectangles -nivoc -hallway to heck -a sinister undercurrent -compound fracture -heap sort -vertebrae -dashed hopes -meerschaum -industrial zone 4 -spin-a-rama -take several pills -able to leap tall robots in a single bound -brownian motion -bifurcation -who dat ninja -mechazawa -moses supposes -evanesce -bamboo battle -baby steps -hand solo -moving target -the art of negotiation -consumer x -hold it -illusory -whomping -leaps and bounds -downtown rush hour -mincome arcology -elevation -vertical theory -soul skimmer -spin class -plus pit -pokeyman -please sir, can i not have another? -red light/red light -blern! -honeyed -you'll die if you try -propellorhead -the easy way -optimizer -ninja's first walljump -drone race -xenopool -below the root -optical smattering -comfort level -sphere of existence -tailgater -step on a crack, break your ninja's back -box-shaped heart -checkmate -decoder ring -the jeezly -return trip -n-key kong -outrun -refactory -there for the taking -by will alone -a daring daylight raid -effervescence -old-school like a sysop in high-tops -superstructured -knot funny -asterisky -the bends -variable aitch -coprocessor -kohoku -ninja's first whatever the hell that is -under the sea -O my -maas -ka-razy -danger dance B -a bit gold-fashioned -out of the blue 2 -starscreams -straylight axis -data base -jinx malloy -mazemerizing -the pit and the n-dulum -escarpment -all your base are belong to their respective owners -pale grey sky -let's hope the giant bees are on vacation -fields, endless fields.. -ninja's first balancing act -exciting opportunities for advancement -stop all the downloadin' -dead end -o gymru -near-death experiences -concentric -n-tropy -lithium-ionized -dead ends -underground -electric avenue -fort awesome -neuroscan -avoidance tactic -fine-toothed comb-over -glutney -control -superliminal x -loopy -neural net -die trying -everything i long for -hex sick -the pits -evac d -deft punk -the honey tree -death by misadventure -pontifex -the perfectra -particulate -to blave -stung -candy dish -majestic -put on your dancing shoes -oldschool like aztecs but new in other aspects -the jeezster -sands of time -good as gold -the name of the rose -skyfires -dances with rockets -integument -methodical A -the ultimate -obstacle cores -ninja's first temptation -b-steps -stimulant -extremely cross -billesque -tricksy -hampered -hidey holes -de vermis mysteriis -the jackpot -choose your own misadventure -snatch and grab -i am the cancer -docking mechanism -efficacy -greentone -postmod -recurring nightmare -who knew drones moved with such agonizing sloth? -take it or grieve it -twice round -thumb-twister -ender -fear the reaper -golgi apparatchik -anatomy of a level -digital distortion -wait for it.. -feathered -stock prices -bunker down -hypnotic -shimmy shakes -en passant -slow nerve action -speedo -mu -shut it down -solaar -broke n hearted -Droids 'B' Us -keep away -industrial zone X -abandoned facility -somewhere over the greynbow -teeth -jumping someone else's train -rooftop -daredevil -8-bit canyon -unstable -superliminal -ramen brother -asymptote -gauss darn it -bypass -knackered -i'm going to marry a caret -Level 5, Detention Block AA-23 -improv -self-serve -G(old) turns to D(eath) -smoothsteps -river run -fribbulus xax -daring do -barglers -made muck of -rotary club -a thousand times no -stantastic -all is full of love -interlocking -master control -pipeworks -round and round -crammy plus -rhomba -rhetorical device -chandeliers -blocked -headspace -tooth skin -please securely fasten the controller wrist strap -interior node -precursor -rough around the edges -two trick pony -snaggy cleft -just keep it moving -it's more scared of you than you are of it -it's not a race. oh wait, it is. -interference pattern -a bang and a whimper -the return of forever -fear is the little-death -concealed -hungry, hungry ninjas -the larch -I hope that someone gets my ninja in a bottle -dizzy spell -unfinished business -chev -sudden valley -undersampling -blatant danger -blue-grey cross -softshoe -shell game -things that make you go boom -electric jellyfish -killswitch -you needn't worry about your reward -snuff box -spot the differences -dilated -twice removed -smattering x -access tunnel -a precipice for disaster -back to school -attrition -unsuperimpossible -the almighty lorg -gronnick -deku -unsafe passage -blast ceiling -fort cut -binary star -strange growth -fire eater -meandering -the jaggies -artifactory -metaphysical -sandtraps -prescience -gobias industries -goldaholics anonymous -ode to a small lump of green putty -stratego -the valley of the shadow -central processing south -there's no i in teamocil... -control freak -remain calm -losing control -deathophobia -cumulonimble -feeding time -hither and yon -bluffs -chipset -sabot-n-der -all-terrain flow -dizzing -the minerals -slinky wizard -rocket cacciatore -cavern -do it yourself -i set my mind in motion -mo' mentum -left branch -pear-shaped -solar power -stacks of wax -explosivo -leptons -spy row die rah -exposed faces -flying circus -don't fear the reaper -funjtables -diamond caves -vitamin yay -timing 101 -alignment -post-cyberpocalypse -jumping through hoops -swirl -serenity now -vaguely satanic chamber -simplicity itself -target -air matrices -the longest yard -hard sync -by misadventure -trekker -like liquid peppermint -the long and winding road that leads to your death -dance -grudge match -twinkle twinkle little gold -rage-o-hol -original exit strategy -heavenly s-word -comlink -descent -glutton for punishment -in the depths of the Slor -core -assisted suicide -cyclic redundancy -sumo -wafflish -evac 2 -fear is the mind-killer -sucks to your asmar -spring training -skip it -slurmy slonka -deep grey sea -tread on my dreams -monument -methodical B -life after deaths -mortal pong -level with no name -the other matrix -like the circles that you find in the windmills of your mind -requiem for a ninja -precariarity -cavern and shirley -trench warfare -upstream -crag -preschool of hard knocks -halfway -modus pwnens -enclave -tremors -inward -polkaroony -go for self -mine field -what does it mean if your spirit animal is a skeletal fish? -dipsy doodle -pincer movement -grand prix -confined -into the lion's nest -mrs. dash -simplicities -dip down -stalag might -pit of despair -on the back of the great ninjasaurus -breathing room -relearning to fly -don't go there -twotone -crossfader -the crucible -victory lap -infiltrate -the creeper-downer -ninja's first rush job -band of gold -MCP -path planner -the ringer -a neural net processor -underground agent -genius -brain slugs -wrong side of the road -the boxer -deathtraps -tantalizingly irresistible -pussyfooting -unadulterated (exterior) -the fundamentals -windswept -spassable -i am a rhombus -wall to wall -picturesque -crooked -buzzbomb -vertizontal -the child out of wedlock -sgruts -out of the blue -hanger -subterfuge -ziggurat vertigo -think of it as a challenge -boom -tyrell 2 -rocket run -superliminal 6 -Sakurajima -automatapaella -trench run 2 -stantasy -a maze zinger -zeb rah -stippled hallway -you might be on B squad -101 damnations -coming down is the hardest thing -zappa -chaotic constellation -claustro -security grid -the squigglies -chock full of crazy -ruination -floating fortress -pinched -central processing -silverfish -biphase edit -exit strategy iv -swarm weather -the tester -plateau -quantic -never ever -watch your step -cubish -strongbox -ruined -b-cakes -a perfectly cromulent world -ever upward -stitch n ditch -runoffs -brain n-eurysm -close encounters -pinchy -squarely -spossible -crunch timing -the pillar of dreams -praise the lorg -rippled -manimal lust 2: return of the franchise -disrecognized space -thank the glord -scrum -oubliette -eruptive -pattern recognition -under pressure -zenith -n-trapment -sibilant S -killswitch? -lull fortress -the reason you broke your keyboard -boundish -platitude -the hidden fortress -totally skewed -fallout -great expectorations -lab -increnect -return trap -natty flow -get well soon lappy! -arterial crescendo -back and forth -Stores of Hell -hexactly -pinnacle -striving -death by murder device -unsafe at any speed -the guidelines -astonishingly sane -fear of flying -the second-last straw -your second-worst nightmare -the punisher -the lowest highest point -intimidation -traction -beach head -insurmountable -a false sense of security -nbolism -funnely -unadulterated (interior) -paper thin walls -defunct -danger dance 1 -out of the blue 4 -laccolith -control yourself -and now, young ninja, you _will_ die. -contra -skeksis -shear -mazer -blastphemy -a deadly game of chess -i make paintings based on grids just like chuck close -death star -losing your mined -amortized -val-n-tine -daunting -the seven gates of heck -one heck of a ride -accelera -hepped up -panic chamber -a strangely isolated place -robot heaven -you can't take it with you -quixotic -crabbed and claw-like -pitcher plant -the last straw -around the world in 80 ways -a close shave -starfire -in a pinch -slippery trip hazard diff --git a/names-SS.txt b/names-SS.txt deleted file mode 100644 index 47d22acb..00000000 --- a/names-SS.txt +++ /dev/null @@ -1,120 +0,0 @@ -inter zone -E8M2: JP LeBreton -occam's razor's razor -click bait -hypercomplexity -trapped beneath the surface -some backwards bullsh++ -raster disaster -textbook inversion -second annual nervous breakdown -solid metal gold -sideways salmon run -lost the plot completely -MFA -exploder in chief -photon flashback -can't get free -cloud colonist -zone of avoidance -terrrible -zeiss-ikon -in loco parenthesis -unitor -all numbers are imaginary -stack tracer -laser chess -a blood oath to defy evil -pseudonimble -engaging with materiality -alien orientation -air academy -rocket surprise -/ // / / // / // -an unconventional solution -loosen up -viral basin -the ninja who wanted everything -peek-a-bang -the ruins of 199X -possibilities' pace -torpedo theodore -remixed metaphors -atomic dustbin of history -overproduction -go go wadjet eye -vertical variations volume v -hyperdeathballad -lack-of-space harrier -it's very stimulating -irruncible differences -archipeligro -rebundant -automatical vvvhard -laser-assisted cognition -stochastic exemption -rhomper -against the wall -the thirty-seventh strategem -abstruse -strike four -mancer -i know this -the crusher -overcooked -symbolic frolic -threap chills -galaxuneasy -nougaty norm -golden opportunities -decayed orbital colony -across the sky -penultimate contraption -continuum singularity -gibsonesque landscape -12-bit hack -break new soil -upwards to infinity -anisotropic -deathtrapped -funstructured space -walljumpclusion -containment breach -daren't -galactickled -rampant ai ant colony 8952-b -altered -delimited -abandoned cavern tower figment -unquantified -nrevac zerwol -feigned -timelords -crowding -f++ing consumer -oh imperfect memory -xtrema -look around you -colorOut.a = 0.666f; -ponderosa -dark castle -authenticity -whoop -one who has suttned -robot's intuition -possible as hell -logo location -blon -a delightful little pile of secrets -vertical variations volume viii -plascrete -is it getting hot in here? -wild abandon -occam's entire shaving kit -deception -the dependancy graph of future events remains strongly connected -redacted -360 no hope -overcrowded -verb the adjective noun -the mad addler diff --git a/names-SS2.txt b/names-SS2.txt deleted file mode 100644 index e0586754..00000000 --- a/names-SS2.txt +++ /dev/null @@ -1,120 +0,0 @@ -technicality -yestalgia -ascenderson -post-nothing -deepchord -fugue state generator -poor impulse control -set me on the couch -slight irregularities -escape from tomorrow -please consult your emergency escape pod procedure handbook for further instruction -jam on it -faster than light -anti-spinward -grain by grain -figurati -dreams are only clouds that form and dissipate -time's arrow in the knee -tremulous spaces -memory palace -fluttermind -the twins and i -message-intercept base -scary pop-ins -gagoosh -sewing a parachute on the way down -switchback -tap dancer -blunderfoot -tears of a clone -clownstep -looking rather peaked -nbroglio -vertical reaping device -time shift experience -against the grain -7th swarming of the machines -dope epode -true timeless mode -delaying the inevitable -illogistics -a path with no obstacles -challenger -firesign -turning into myself -the professor -chaingun cha-cha -zenny -mayhem -unspooling -finger fascinator -send in the clones -stop following me! -kitbashed -pitter-pattern -legrange points -demodulated -a desperate mission -spectral enhancer -scratchware manifesto -destroyal -three on one -game over hell -death is not the end -tone tattoo -flow coma -fear of enclosed spaces -time is an axis -full power side attack -glord almighty -glowing wireframe nonsense broth -deep breathing technique -turning against one's own person -pac monster -heist of the century -laminal voiceless alveolar non-sibilant fricative -too close for comfort -second cousin of pit of despair -no escape -constantly haunted -inadequatulence -skip rope -somewhat of a shocker -fatal ballet -past-Os -never tell me 'never tell me the odds' -wild ride -dark calculus -drowned by sight -i hope i shall arrive soonish -eeny meeny mine-y moan -lustrations -put a stamp on that and mail it to yourself -centers of distraction -nigh-impossible -iterative deepening -robot command center -compendium for literates -trials by error -wicked weapons -regretrospective -wastrel -photon phantoms -pho -ultra klutz -here comes the rain again -goofy footed -imprism'd -hell hath fury -so like, what's an infinity zone? -say when ++ -not so octopus -leave planet earth -surface extrapolator -suave and suffocated -self sabotage -few mysteries solved in a year of contact -majesty shredding -return of the pitcher plant -scorched earth diff --git a/names-SU.txt b/names-SU.txt deleted file mode 100644 index 3cefb9b9..00000000 --- a/names-SU.txt +++ /dev/null @@ -1,600 +0,0 @@ -868-JUMP -einfact -who has time to get gold? -ticked off -making your own way -brake beats -timidity -fiendishly easy -minimum security -jumpstyle -rotatoes -silent running -blammo -rock climbing -the route of all evil -no rush -relative space -goldopolis -an open and shut case -abandonware -ceil() -three-ring circus -trapped in a micro pattern -recursive ascent parser -archetypal -tip: top -what are your overheads?! -mastabas -self-potato -considered harmful -truncated -taken aback -the jumpening -pavlov's ninja -no running in the hallway -railings of doom -platform blindness -rather poorly designed killing chambers -cut out -inverted access -super colliders -metaphysical transporter -no clipping -auxiliary patroller -brainial stimulation -the noodler -lasey -serial jump zone -pocket rocket -hesitation -over it -gate sequencer -through the habitrails -rocket room -sequelitis -vertical loop -that forever empty -flexibility -nanotechnological space flower -concrete garden -a little too light on the hippity hop -a real gold-getter -loose -sharded -accidentally quadratic -meat tornado -invasive maneuvers -disposable training wheels -quadruple jeopardy -spirogyrations -watch -crest -takes time -scared of your own shadow -creation -mille plateaux -tiny problems -extra credit hallway -microgues -oh my frangible heart -singles -erosive design -sku -time is just a measurement of life -plurality -platformative years -detourette -be-shot-em-up -florbity blorbs -reiteration -sheets to the wind -double-taps -the wobblies part 2 -god speed -surprise ending -toggle time -soft failure -effective accelerant -angry breadcrumbs -primitive drift -props -machime learming -wonky -huckleberry fiend -fettuccine afraido -chain reactions -mesmerized -smoke and bass -a slight asymmetry -short and bittersweet -videons -substance p -intelligent systems -extra effort -synchronize watches -penalty method -not a fan of the pop-ins -development -the V -insanity check -overview effect -monster bag -insert credit -flumps -sprezzatura -rounding up -inordinate -tegotae -off the deep end -lil fizzlers -shooting the needle -a vast oversimplification of the truth -ye roof -excuse you -enter the bargle -pac animal -lingering fingerlings -one-sided -jumpire -don't fear the micro reaper -superfluidic -the eyes themselves -technique -FALLCAPS -just breathe -miyamojo -holkham drones -moderately collimated -the last geometer -the sound of one hand slow-clapping -perp hexing -trippy j -running down like water -in a trice -dig me back in -zenzizenzizenzic -today i die repeatedly -optimiserly -keep it moving -methods of rationality -robotic circulation -diamond eyes -pokaayy -tuber -every which way but dead -shagargamel -tutorial flashback -turns for the worse -future history -reason is irrelevant -jump around -getRect() -chop boxes -ear nose and throat of the beholder -monster of symmetry -flaunt the style -extemporization -hoopla -speed test -shmaser shmequence -commit process -hands be dexterous -sleek -a scape -one by one by one by one by one by one by one -nested -a brief treatise on the evasion of rockets -ringsly -that is not a safe hidey-hole -internal decay -fatal attraction -ruined ruins -advance -run -transposed -narrow margins -training simulator -detonator -tiled memory -mind weapon -spacious -treacherous terrain -the sinister square hedges -floating points -crikey -summit plummet -falling blocks -finite error tolerance -rockety meadows -method of loci -procedural rhetorical question -moustache zone -weaving shock -game parallelepiped -loopy -irregularities -chariots of ire -snikes -core red -hard divisors -glordy -meshy -the world you will have known -weird gear -inopportunities -thrill climbing -slag heaps -aesthetic simulations -technofuturist -runway -pitcher factory -cyclical nature -eternal vigilance -scanbrain -glitchfield vertices -asimine -weave world -panoptical -smitten -escape aid -pha -notional space -advanced rudiments -butterfeet -null propagation -evil core -terror arithmetic -mega damage capacity -titular -open season -kaaaa-wiiiizzz! -archangle -onceless -raining electric death -advanced studies in the platforming arts -moderation -neutra -temporary bridge -clouded -artificial biomes -razorwire -even if and especially when -tripartite -bubble metropolis -blamco industries -ceiling cacti -futurism -something of an intrusive inconvenience -ghosts at the feast -the collectathon -tippy-toe -balk zone -optimal pathfinding -steppes -the memcordist -sphygmomanometer -saccade -plaque kills -obelisk speed dating -absolute bounder -skip tracer -optional -basswave -the great escape -a frankly unbelievable amount of doors -caltropped -the speed of light -zarniwhoops -ironic nostalgia does not an artist make -temerity -diagonal environment -stifling -megastructural -turn styles -globins -gravity's inexorable pull -security grid redux -crescendon't -linear extrapolation -6955 -hop springs eternal -perp walls -corpse corps -blamboo shoots -jam packed -boost it -refractory factory -the tower at the end of the world -speedbumps -artificial gravity -taking the plunge -mighty vision -denouementality -pause for thought -murmurations -speak through play -dexterity testing apparatus -whither yon ninja -execration -the unlockening -walljumpertunities -vertical prison -folded space -ineluctable -hallway of hesitation -no peaking -haunted tomb -dang -sans soleil -unbreak my back -sqworm -exit strategy ii -deterioration -killisthenics -room for improvement -heaps of trouble -rocketpalooza -completely hortizontical -a severe stroboscopic effect -chain of memories -save some for the way back -hexpiral -convergent evolution -spiral staircase -the lost art of knowing your limits -double entrapment -two-stage process -test pattern 1 -some kind of crazy drone magic -the catch -impacted on the surface -the return of the menace -leap frogger -mine pressure -arcane action -a most irregular cavern -violet psycho -spinward -death spiral -moving parts -rocket rush -METEOR -the boss matrix -deep underground -dastardly death maze -breakable -near earth hub -good luck with that one -once loved accidental masterpiece -the puttanesca trench -beneath a steel sky -practice had made pluperfect -squid yes -arrondissements -convolution -inertial frame -flasers -cracked LCD -the usefulness of pacing -completely ordinary attractors -gorgeous gorge -maps of bounded rationality -passage -one thing after another -wizard mode -elder moustache god -blasterpiece -chamber sequence -pits of death -fleeting opportunities -flubbyholes -too much go juice -ninjas we have hurt on high -the oct -something something the wings of icarus -the creeper-upper -murderorb -rocket workout -the puzzler -allpass filter -cryptic contamination -symbolic processing -floor tricks -underpinning -death lorg -smiles times -foresight -the laser matrix -vats -synthetic substitution -tippy -a bountain of work -brainer -the importance of walljumping facility -pipe troubled -nostalgia for the absolute -kind of a downer -the dronies -blaze of glory -whirlwinded -outlined -the chamber -launch trailer -trouble fitting in -fear of repeating 'fear of repeating yourself' -symbolic exemplar -the jump-master general -sniper's den -return of the meat tornado -stop hitting yourself -over-under -von braun -evilocity -immortality project -sigma structure symphony -circular circus -exit strategy iii -septuple dipping -candy-wrapper artifact -composure -opposing pairs -last known valid context -napoleon blownapart -this hyperbolic chamber is BIGGER THAN THE SUN -tragedy plus plus time -progression -traffic patterns -velocity of desire -shunted -rocket observator -hocus pocus -that one damn piece -superceded sequence breaker -anticipation -a fate worse than a fate worse than death -dimensional exploration -sneaking around -haunted mountain -photon finish -flightplanning -warpwing -slippy -trapped in a pattern -funhouse -logic gates -praywalls -minimal cover -type 1 critical contingent weapon -serpentes -wild language -zappity doodads -go boldly -list of inappropriate words -never stop -the upwards stampede -malignant mountain -the school for poetic computation -crouching tiger hidden wire -flying high -some kind of alien cavern -gaffe riot -gravity's ghost -index out of bounds -through the roof -maelstrom'd -the outward spiral -the lost art of dreaming -rocket rings -thereof one must be silent -synchronized swimming -strategic strategy -stepping on your own toes -trickled pink -stimming -the exit from hell -up in the air -mine control -pearl diver -intergalactic iconography -end sinister -bait -the most bog-standard laser-chimney you've ever seen -the sprint pit -deja FFFFUUUU -enemies closer -attention capture -ending riddle -cups -core blue -an embarrassment of switches -sundry jargons -fly pods -GL_LINE_STIPPLE(SP_REMIX) -helmet fire -losing ground -thrice shafted -disasterisk -focused -squiggle chase -every time i wake -light show -one million billionth of a millisecond -license to kill yourself -triplus -sequential circuit -desktop tableau -quad emotional damage -tongue-through-cheek -bent -industrial divinities -laser precision -slipping away -photon torpedoed -slalom -invaded -rent ballad -faith is a fact -hard to judge -good, better, best, bestest -double double chase -trickery -dipper -seemingly impossible -cavities -thriller -arduous climb -pokeys haunt us -big softy -v for very long jump -laser checkmate -the sweats -freezone -why did it have to be lasers?? -ever upwards -finishing moves -endeavours immeasurable -betweem -nukage pits -the wizard of odds -you can never quarantine the past -conjecture -12-dimensional rocket chess -pas de trois -s-rank -eyes on the road -medium hadron collider -a decided lack of flooring -mine master -teeth chase -cogito ergo something -the steepening -you can't speedrun a broken heart -the jaws of infinity -to the pain -shortness of breath -learning process -tempus f++kit -in the halls of the mountain king -the wall -fusilladed -the art of deft -gradient ascent -mr griddler -in a pinch -never never never never never land -launch windows -terror management theory -space chamber -chaotic attractors -network dynamics -lumbricus -terrifyingly sane -rocket arena -unknowable geometry -oblivion seekers -clone high -grandmastery -wave of terror -the tenth circle of hell -destroyer of worlds diff --git a/screenshots/S-A-00-00.jpg b/screenshots/S-A-00-00.jpg deleted file mode 100644 index ff3afd76..00000000 Binary files a/screenshots/S-A-00-00.jpg and /dev/null differ diff --git a/screenshots/S-A-00-01.jpg b/screenshots/S-A-00-01.jpg deleted file mode 100644 index 3c5ed049..00000000 Binary files a/screenshots/S-A-00-01.jpg and /dev/null differ diff --git a/screenshots/S-A-00-02.jpg b/screenshots/S-A-00-02.jpg deleted file mode 100644 index ef899a2f..00000000 Binary files a/screenshots/S-A-00-02.jpg and /dev/null differ diff --git a/screenshots/S-A-00-03.jpg b/screenshots/S-A-00-03.jpg deleted file mode 100644 index 7b90a0a9..00000000 Binary files a/screenshots/S-A-00-03.jpg and /dev/null differ diff --git a/screenshots/S-A-00-04.jpg b/screenshots/S-A-00-04.jpg deleted file mode 100644 index 77479d90..00000000 Binary files a/screenshots/S-A-00-04.jpg and /dev/null differ diff --git a/screenshots/S-A-00.jpg b/screenshots/S-A-00.jpg deleted file mode 100644 index ac954d97..00000000 Binary files a/screenshots/S-A-00.jpg and /dev/null differ diff --git a/screenshots/S-A-01-00.jpg b/screenshots/S-A-01-00.jpg deleted file mode 100644 index f250a948..00000000 Binary files a/screenshots/S-A-01-00.jpg and /dev/null differ diff --git a/screenshots/S-A-01-01.jpg b/screenshots/S-A-01-01.jpg deleted file mode 100644 index 827146ed..00000000 Binary files a/screenshots/S-A-01-01.jpg and /dev/null differ diff --git a/screenshots/S-A-01-02.jpg b/screenshots/S-A-01-02.jpg deleted file mode 100644 index 12d72c36..00000000 Binary files a/screenshots/S-A-01-02.jpg and /dev/null differ diff --git a/screenshots/S-A-01-03.jpg b/screenshots/S-A-01-03.jpg deleted file mode 100644 index d0125bfc..00000000 Binary files a/screenshots/S-A-01-03.jpg and /dev/null differ diff --git a/screenshots/S-A-01-04.jpg b/screenshots/S-A-01-04.jpg deleted file mode 100644 index 53bc4bd4..00000000 Binary files a/screenshots/S-A-01-04.jpg and /dev/null differ diff --git a/screenshots/S-A-01.jpg b/screenshots/S-A-01.jpg deleted file mode 100644 index bc82b6bd..00000000 Binary files a/screenshots/S-A-01.jpg and /dev/null differ diff --git a/screenshots/S-A-02-00.jpg b/screenshots/S-A-02-00.jpg deleted file mode 100644 index 29f586de..00000000 Binary files a/screenshots/S-A-02-00.jpg and /dev/null differ diff --git a/screenshots/S-A-02-01.jpg b/screenshots/S-A-02-01.jpg deleted file mode 100644 index e9b28aa0..00000000 Binary files a/screenshots/S-A-02-01.jpg and /dev/null differ diff --git a/screenshots/S-A-02-02.jpg b/screenshots/S-A-02-02.jpg deleted file mode 100644 index 4981db16..00000000 Binary files a/screenshots/S-A-02-02.jpg and /dev/null differ diff --git a/screenshots/S-A-02-03.jpg b/screenshots/S-A-02-03.jpg deleted file mode 100644 index 2ebd4142..00000000 Binary files a/screenshots/S-A-02-03.jpg and /dev/null differ diff --git a/screenshots/S-A-02-04.jpg b/screenshots/S-A-02-04.jpg deleted file mode 100644 index ca7dd18b..00000000 Binary files a/screenshots/S-A-02-04.jpg and /dev/null differ diff --git a/screenshots/S-A-02.jpg b/screenshots/S-A-02.jpg deleted file mode 100644 index 412d14ac..00000000 Binary files a/screenshots/S-A-02.jpg and /dev/null differ diff --git a/screenshots/S-A-03-00.jpg b/screenshots/S-A-03-00.jpg deleted file mode 100644 index 14dac8ea..00000000 Binary files a/screenshots/S-A-03-00.jpg and /dev/null differ diff --git a/screenshots/S-A-03-01.jpg b/screenshots/S-A-03-01.jpg deleted file mode 100644 index 0f9875a6..00000000 Binary files a/screenshots/S-A-03-01.jpg and /dev/null differ diff --git a/screenshots/S-A-03-02.jpg b/screenshots/S-A-03-02.jpg deleted file mode 100644 index 23e538f6..00000000 Binary files a/screenshots/S-A-03-02.jpg and /dev/null differ diff --git a/screenshots/S-A-03-03.jpg b/screenshots/S-A-03-03.jpg deleted file mode 100644 index 9bc99741..00000000 Binary files a/screenshots/S-A-03-03.jpg and /dev/null differ diff --git a/screenshots/S-A-03-04.jpg b/screenshots/S-A-03-04.jpg deleted file mode 100644 index 0746ddbb..00000000 Binary files a/screenshots/S-A-03-04.jpg and /dev/null differ diff --git a/screenshots/S-A-03.jpg b/screenshots/S-A-03.jpg deleted file mode 100644 index c5947b8b..00000000 Binary files a/screenshots/S-A-03.jpg and /dev/null differ diff --git a/screenshots/S-A-04-00.jpg b/screenshots/S-A-04-00.jpg deleted file mode 100644 index 1dcf62ea..00000000 Binary files a/screenshots/S-A-04-00.jpg and /dev/null differ diff --git a/screenshots/S-A-04-01.jpg b/screenshots/S-A-04-01.jpg deleted file mode 100644 index f3c03e7e..00000000 Binary files a/screenshots/S-A-04-01.jpg and /dev/null differ diff --git a/screenshots/S-A-04-02.jpg b/screenshots/S-A-04-02.jpg deleted file mode 100644 index 9a50d9e0..00000000 Binary files a/screenshots/S-A-04-02.jpg and /dev/null differ diff --git a/screenshots/S-A-04-03.jpg b/screenshots/S-A-04-03.jpg deleted file mode 100644 index b30a4652..00000000 Binary files a/screenshots/S-A-04-03.jpg and /dev/null differ diff --git a/screenshots/S-A-04-04.jpg b/screenshots/S-A-04-04.jpg deleted file mode 100644 index 61adc8c2..00000000 Binary files a/screenshots/S-A-04-04.jpg and /dev/null differ diff --git a/screenshots/S-A-04.jpg b/screenshots/S-A-04.jpg deleted file mode 100644 index 0c409b35..00000000 Binary files a/screenshots/S-A-04.jpg and /dev/null differ diff --git a/screenshots/S-A-05-00.jpg b/screenshots/S-A-05-00.jpg deleted file mode 100644 index 87d6967d..00000000 Binary files a/screenshots/S-A-05-00.jpg and /dev/null differ diff --git a/screenshots/S-A-05-01.jpg b/screenshots/S-A-05-01.jpg deleted file mode 100644 index f5b9d2a0..00000000 Binary files a/screenshots/S-A-05-01.jpg and /dev/null differ diff --git a/screenshots/S-A-05-02.jpg b/screenshots/S-A-05-02.jpg deleted file mode 100644 index fbb96883..00000000 Binary files a/screenshots/S-A-05-02.jpg and /dev/null differ diff --git a/screenshots/S-A-05-03.jpg b/screenshots/S-A-05-03.jpg deleted file mode 100644 index 4e257042..00000000 Binary files a/screenshots/S-A-05-03.jpg and /dev/null differ diff --git a/screenshots/S-A-05-04.jpg b/screenshots/S-A-05-04.jpg deleted file mode 100644 index 6b612a63..00000000 Binary files a/screenshots/S-A-05-04.jpg and /dev/null differ diff --git a/screenshots/S-A-05.jpg b/screenshots/S-A-05.jpg deleted file mode 100644 index ad11cbce..00000000 Binary files a/screenshots/S-A-05.jpg and /dev/null differ diff --git a/screenshots/S-A-06-00.jpg b/screenshots/S-A-06-00.jpg deleted file mode 100644 index 47b3537d..00000000 Binary files a/screenshots/S-A-06-00.jpg and /dev/null differ diff --git a/screenshots/S-A-06-01.jpg b/screenshots/S-A-06-01.jpg deleted file mode 100644 index 6006a47a..00000000 Binary files a/screenshots/S-A-06-01.jpg and /dev/null differ diff --git a/screenshots/S-A-06-02.jpg b/screenshots/S-A-06-02.jpg deleted file mode 100644 index 91a93e92..00000000 Binary files a/screenshots/S-A-06-02.jpg and /dev/null differ diff --git a/screenshots/S-A-06-03.jpg b/screenshots/S-A-06-03.jpg deleted file mode 100644 index 79a4f9de..00000000 Binary files a/screenshots/S-A-06-03.jpg and /dev/null differ diff --git a/screenshots/S-A-06-04.jpg b/screenshots/S-A-06-04.jpg deleted file mode 100644 index b9678201..00000000 Binary files a/screenshots/S-A-06-04.jpg and /dev/null differ diff --git a/screenshots/S-A-06.jpg b/screenshots/S-A-06.jpg deleted file mode 100644 index 0234767f..00000000 Binary files a/screenshots/S-A-06.jpg and /dev/null differ diff --git a/screenshots/S-A-07-00.jpg b/screenshots/S-A-07-00.jpg deleted file mode 100644 index fc49dc38..00000000 Binary files a/screenshots/S-A-07-00.jpg and /dev/null differ diff --git a/screenshots/S-A-07-01.jpg b/screenshots/S-A-07-01.jpg deleted file mode 100644 index 4e097e3d..00000000 Binary files a/screenshots/S-A-07-01.jpg and /dev/null differ diff --git a/screenshots/S-A-07-02.jpg b/screenshots/S-A-07-02.jpg deleted file mode 100644 index 6439a518..00000000 Binary files a/screenshots/S-A-07-02.jpg and /dev/null differ diff --git a/screenshots/S-A-07-03.jpg b/screenshots/S-A-07-03.jpg deleted file mode 100644 index a967a9cd..00000000 Binary files a/screenshots/S-A-07-03.jpg and /dev/null differ diff --git a/screenshots/S-A-07-04.jpg b/screenshots/S-A-07-04.jpg deleted file mode 100644 index 1616e9f2..00000000 Binary files a/screenshots/S-A-07-04.jpg and /dev/null differ diff --git a/screenshots/S-A-07.jpg b/screenshots/S-A-07.jpg deleted file mode 100644 index 966e73c2..00000000 Binary files a/screenshots/S-A-07.jpg and /dev/null differ diff --git a/screenshots/S-A-08-00.jpg b/screenshots/S-A-08-00.jpg deleted file mode 100644 index 63005138..00000000 Binary files a/screenshots/S-A-08-00.jpg and /dev/null differ diff --git a/screenshots/S-A-08-01.jpg b/screenshots/S-A-08-01.jpg deleted file mode 100644 index 78279511..00000000 Binary files a/screenshots/S-A-08-01.jpg and /dev/null differ diff --git a/screenshots/S-A-08-02.jpg b/screenshots/S-A-08-02.jpg deleted file mode 100644 index 3824d687..00000000 Binary files a/screenshots/S-A-08-02.jpg and /dev/null differ diff --git a/screenshots/S-A-08-03.jpg b/screenshots/S-A-08-03.jpg deleted file mode 100644 index e0492294..00000000 Binary files a/screenshots/S-A-08-03.jpg and /dev/null differ diff --git a/screenshots/S-A-08-04.jpg b/screenshots/S-A-08-04.jpg deleted file mode 100644 index de77d901..00000000 Binary files a/screenshots/S-A-08-04.jpg and /dev/null differ diff --git a/screenshots/S-A-08.jpg b/screenshots/S-A-08.jpg deleted file mode 100644 index ebedd082..00000000 Binary files a/screenshots/S-A-08.jpg and /dev/null differ diff --git a/screenshots/S-A-09-00.jpg b/screenshots/S-A-09-00.jpg deleted file mode 100644 index aff52d74..00000000 Binary files a/screenshots/S-A-09-00.jpg and /dev/null differ diff --git a/screenshots/S-A-09-01.jpg b/screenshots/S-A-09-01.jpg deleted file mode 100644 index d8689c37..00000000 Binary files a/screenshots/S-A-09-01.jpg and /dev/null differ diff --git a/screenshots/S-A-09-02.jpg b/screenshots/S-A-09-02.jpg deleted file mode 100644 index 47b9dc12..00000000 Binary files a/screenshots/S-A-09-02.jpg and /dev/null differ diff --git a/screenshots/S-A-09-03.jpg b/screenshots/S-A-09-03.jpg deleted file mode 100644 index 104ddf25..00000000 Binary files a/screenshots/S-A-09-03.jpg and /dev/null differ diff --git a/screenshots/S-A-09-04.jpg b/screenshots/S-A-09-04.jpg deleted file mode 100644 index 0bf560b7..00000000 Binary files a/screenshots/S-A-09-04.jpg and /dev/null differ diff --git a/screenshots/S-A-09.jpg b/screenshots/S-A-09.jpg deleted file mode 100644 index 62d4aec0..00000000 Binary files a/screenshots/S-A-09.jpg and /dev/null differ diff --git a/screenshots/S-A-10-00.jpg b/screenshots/S-A-10-00.jpg deleted file mode 100644 index 03863403..00000000 Binary files a/screenshots/S-A-10-00.jpg and /dev/null differ diff --git a/screenshots/S-A-10-01.jpg b/screenshots/S-A-10-01.jpg deleted file mode 100644 index dbe07e06..00000000 Binary files a/screenshots/S-A-10-01.jpg and /dev/null differ diff --git a/screenshots/S-A-10-02.jpg b/screenshots/S-A-10-02.jpg deleted file mode 100644 index d399f9df..00000000 Binary files a/screenshots/S-A-10-02.jpg and /dev/null differ diff --git a/screenshots/S-A-10-03.jpg b/screenshots/S-A-10-03.jpg deleted file mode 100644 index a917686b..00000000 Binary files a/screenshots/S-A-10-03.jpg and /dev/null differ diff --git a/screenshots/S-A-10-04.jpg b/screenshots/S-A-10-04.jpg deleted file mode 100644 index dd4f57a5..00000000 Binary files a/screenshots/S-A-10-04.jpg and /dev/null differ diff --git a/screenshots/S-A-10.jpg b/screenshots/S-A-10.jpg deleted file mode 100644 index b66ea757..00000000 Binary files a/screenshots/S-A-10.jpg and /dev/null differ diff --git a/screenshots/S-A-11-00.jpg b/screenshots/S-A-11-00.jpg deleted file mode 100644 index 4f749557..00000000 Binary files a/screenshots/S-A-11-00.jpg and /dev/null differ diff --git a/screenshots/S-A-11-01.jpg b/screenshots/S-A-11-01.jpg deleted file mode 100644 index d0399935..00000000 Binary files a/screenshots/S-A-11-01.jpg and /dev/null differ diff --git a/screenshots/S-A-11-02.jpg b/screenshots/S-A-11-02.jpg deleted file mode 100644 index 0a3ff84f..00000000 Binary files a/screenshots/S-A-11-02.jpg and /dev/null differ diff --git a/screenshots/S-A-11-03.jpg b/screenshots/S-A-11-03.jpg deleted file mode 100644 index 991768d2..00000000 Binary files a/screenshots/S-A-11-03.jpg and /dev/null differ diff --git a/screenshots/S-A-11-04.jpg b/screenshots/S-A-11-04.jpg deleted file mode 100644 index 8570ff88..00000000 Binary files a/screenshots/S-A-11-04.jpg and /dev/null differ diff --git a/screenshots/S-A-11.jpg b/screenshots/S-A-11.jpg deleted file mode 100644 index ce60a94d..00000000 Binary files a/screenshots/S-A-11.jpg and /dev/null differ diff --git a/screenshots/S-A-12-00.jpg b/screenshots/S-A-12-00.jpg deleted file mode 100644 index 041a6e49..00000000 Binary files a/screenshots/S-A-12-00.jpg and /dev/null differ diff --git a/screenshots/S-A-12-01.jpg b/screenshots/S-A-12-01.jpg deleted file mode 100644 index 350f879b..00000000 Binary files a/screenshots/S-A-12-01.jpg and /dev/null differ diff --git a/screenshots/S-A-12-02.jpg b/screenshots/S-A-12-02.jpg deleted file mode 100644 index 49137ea6..00000000 Binary files a/screenshots/S-A-12-02.jpg and /dev/null differ diff --git a/screenshots/S-A-12-03.jpg b/screenshots/S-A-12-03.jpg deleted file mode 100644 index 1bb68c63..00000000 Binary files a/screenshots/S-A-12-03.jpg and /dev/null differ diff --git a/screenshots/S-A-12-04.jpg b/screenshots/S-A-12-04.jpg deleted file mode 100644 index ff6ab634..00000000 Binary files a/screenshots/S-A-12-04.jpg and /dev/null differ diff --git a/screenshots/S-A-12.jpg b/screenshots/S-A-12.jpg deleted file mode 100644 index 845470c5..00000000 Binary files a/screenshots/S-A-12.jpg and /dev/null differ diff --git a/screenshots/S-A-13-00.jpg b/screenshots/S-A-13-00.jpg deleted file mode 100644 index f30dc3f8..00000000 Binary files a/screenshots/S-A-13-00.jpg and /dev/null differ diff --git a/screenshots/S-A-13-01.jpg b/screenshots/S-A-13-01.jpg deleted file mode 100644 index 37a50a0b..00000000 Binary files a/screenshots/S-A-13-01.jpg and /dev/null differ diff --git a/screenshots/S-A-13-02.jpg b/screenshots/S-A-13-02.jpg deleted file mode 100644 index d7dc7159..00000000 Binary files a/screenshots/S-A-13-02.jpg and /dev/null differ diff --git a/screenshots/S-A-13-03.jpg b/screenshots/S-A-13-03.jpg deleted file mode 100644 index c0d00453..00000000 Binary files a/screenshots/S-A-13-03.jpg and /dev/null differ diff --git a/screenshots/S-A-13-04.jpg b/screenshots/S-A-13-04.jpg deleted file mode 100644 index 3a409419..00000000 Binary files a/screenshots/S-A-13-04.jpg and /dev/null differ diff --git a/screenshots/S-A-13.jpg b/screenshots/S-A-13.jpg deleted file mode 100644 index d135aefd..00000000 Binary files a/screenshots/S-A-13.jpg and /dev/null differ diff --git a/screenshots/S-A-14-00.jpg b/screenshots/S-A-14-00.jpg deleted file mode 100644 index 8501db71..00000000 Binary files a/screenshots/S-A-14-00.jpg and /dev/null differ diff --git a/screenshots/S-A-14-01.jpg b/screenshots/S-A-14-01.jpg deleted file mode 100644 index dc566287..00000000 Binary files a/screenshots/S-A-14-01.jpg and /dev/null differ diff --git a/screenshots/S-A-14-02.jpg b/screenshots/S-A-14-02.jpg deleted file mode 100644 index 8d6a428f..00000000 Binary files a/screenshots/S-A-14-02.jpg and /dev/null differ diff --git a/screenshots/S-A-14-03.jpg b/screenshots/S-A-14-03.jpg deleted file mode 100644 index 76b060ed..00000000 Binary files a/screenshots/S-A-14-03.jpg and /dev/null differ diff --git a/screenshots/S-A-14-04.jpg b/screenshots/S-A-14-04.jpg deleted file mode 100644 index f44f0f4e..00000000 Binary files a/screenshots/S-A-14-04.jpg and /dev/null differ diff --git a/screenshots/S-A-14.jpg b/screenshots/S-A-14.jpg deleted file mode 100644 index 820b8fbc..00000000 Binary files a/screenshots/S-A-14.jpg and /dev/null differ diff --git a/screenshots/S-A-15-00.jpg b/screenshots/S-A-15-00.jpg deleted file mode 100644 index ecf448a2..00000000 Binary files a/screenshots/S-A-15-00.jpg and /dev/null differ diff --git a/screenshots/S-A-15-01.jpg b/screenshots/S-A-15-01.jpg deleted file mode 100644 index 086a4c5f..00000000 Binary files a/screenshots/S-A-15-01.jpg and /dev/null differ diff --git a/screenshots/S-A-15-02.jpg b/screenshots/S-A-15-02.jpg deleted file mode 100644 index 77106c4b..00000000 Binary files a/screenshots/S-A-15-02.jpg and /dev/null differ diff --git a/screenshots/S-A-15-03.jpg b/screenshots/S-A-15-03.jpg deleted file mode 100644 index e85194e5..00000000 Binary files a/screenshots/S-A-15-03.jpg and /dev/null differ diff --git a/screenshots/S-A-15-04.jpg b/screenshots/S-A-15-04.jpg deleted file mode 100644 index dce46f4e..00000000 Binary files a/screenshots/S-A-15-04.jpg and /dev/null differ diff --git a/screenshots/S-A-15.jpg b/screenshots/S-A-15.jpg deleted file mode 100644 index 0a707bbf..00000000 Binary files a/screenshots/S-A-15.jpg and /dev/null differ diff --git a/screenshots/S-A-16-00.jpg b/screenshots/S-A-16-00.jpg deleted file mode 100644 index 0f3881be..00000000 Binary files a/screenshots/S-A-16-00.jpg and /dev/null differ diff --git a/screenshots/S-A-16-01.jpg b/screenshots/S-A-16-01.jpg deleted file mode 100644 index 6bcd28bc..00000000 Binary files a/screenshots/S-A-16-01.jpg and /dev/null differ diff --git a/screenshots/S-A-16-02.jpg b/screenshots/S-A-16-02.jpg deleted file mode 100644 index aa366775..00000000 Binary files a/screenshots/S-A-16-02.jpg and /dev/null differ diff --git a/screenshots/S-A-16-03.jpg b/screenshots/S-A-16-03.jpg deleted file mode 100644 index cb10953b..00000000 Binary files a/screenshots/S-A-16-03.jpg and /dev/null differ diff --git a/screenshots/S-A-16-04.jpg b/screenshots/S-A-16-04.jpg deleted file mode 100644 index b2de431c..00000000 Binary files a/screenshots/S-A-16-04.jpg and /dev/null differ diff --git a/screenshots/S-A-16.jpg b/screenshots/S-A-16.jpg deleted file mode 100644 index 3f191565..00000000 Binary files a/screenshots/S-A-16.jpg and /dev/null differ diff --git a/screenshots/S-A-17-00.jpg b/screenshots/S-A-17-00.jpg deleted file mode 100644 index b78bf12b..00000000 Binary files a/screenshots/S-A-17-00.jpg and /dev/null differ diff --git a/screenshots/S-A-17-01.jpg b/screenshots/S-A-17-01.jpg deleted file mode 100644 index 0a15a461..00000000 Binary files a/screenshots/S-A-17-01.jpg and /dev/null differ diff --git a/screenshots/S-A-17-02.jpg b/screenshots/S-A-17-02.jpg deleted file mode 100644 index 7f1297cd..00000000 Binary files a/screenshots/S-A-17-02.jpg and /dev/null differ diff --git a/screenshots/S-A-17-03.jpg b/screenshots/S-A-17-03.jpg deleted file mode 100644 index 1fe88a58..00000000 Binary files a/screenshots/S-A-17-03.jpg and /dev/null differ diff --git a/screenshots/S-A-17-04.jpg b/screenshots/S-A-17-04.jpg deleted file mode 100644 index 268f9aaf..00000000 Binary files a/screenshots/S-A-17-04.jpg and /dev/null differ diff --git a/screenshots/S-A-17.jpg b/screenshots/S-A-17.jpg deleted file mode 100644 index 4075388c..00000000 Binary files a/screenshots/S-A-17.jpg and /dev/null differ diff --git a/screenshots/S-A-18-00.jpg b/screenshots/S-A-18-00.jpg deleted file mode 100644 index 6f4e797e..00000000 Binary files a/screenshots/S-A-18-00.jpg and /dev/null differ diff --git a/screenshots/S-A-18-01.jpg b/screenshots/S-A-18-01.jpg deleted file mode 100644 index 2b90c8bc..00000000 Binary files a/screenshots/S-A-18-01.jpg and /dev/null differ diff --git a/screenshots/S-A-18-02.jpg b/screenshots/S-A-18-02.jpg deleted file mode 100644 index bceec4a2..00000000 Binary files a/screenshots/S-A-18-02.jpg and /dev/null differ diff --git a/screenshots/S-A-18-03.jpg b/screenshots/S-A-18-03.jpg deleted file mode 100644 index 032bf70f..00000000 Binary files a/screenshots/S-A-18-03.jpg and /dev/null differ diff --git a/screenshots/S-A-18-04.jpg b/screenshots/S-A-18-04.jpg deleted file mode 100644 index 62e59905..00000000 Binary files a/screenshots/S-A-18-04.jpg and /dev/null differ diff --git a/screenshots/S-A-18.jpg b/screenshots/S-A-18.jpg deleted file mode 100644 index cb3ff8f0..00000000 Binary files a/screenshots/S-A-18.jpg and /dev/null differ diff --git a/screenshots/S-A-19-00.jpg b/screenshots/S-A-19-00.jpg deleted file mode 100644 index f8740f9d..00000000 Binary files a/screenshots/S-A-19-00.jpg and /dev/null differ diff --git a/screenshots/S-A-19-01.jpg b/screenshots/S-A-19-01.jpg deleted file mode 100644 index c4a2b0e0..00000000 Binary files a/screenshots/S-A-19-01.jpg and /dev/null differ diff --git a/screenshots/S-A-19-02.jpg b/screenshots/S-A-19-02.jpg deleted file mode 100644 index be3f172d..00000000 Binary files a/screenshots/S-A-19-02.jpg and /dev/null differ diff --git a/screenshots/S-A-19-03.jpg b/screenshots/S-A-19-03.jpg deleted file mode 100644 index 0fcb7c64..00000000 Binary files a/screenshots/S-A-19-03.jpg and /dev/null differ diff --git a/screenshots/S-A-19-04.jpg b/screenshots/S-A-19-04.jpg deleted file mode 100644 index b70de2b6..00000000 Binary files a/screenshots/S-A-19-04.jpg and /dev/null differ diff --git a/screenshots/S-A-19.jpg b/screenshots/S-A-19.jpg deleted file mode 100644 index 3e254259..00000000 Binary files a/screenshots/S-A-19.jpg and /dev/null differ diff --git a/screenshots/S-B-00-00.jpg b/screenshots/S-B-00-00.jpg deleted file mode 100644 index 593b06a3..00000000 Binary files a/screenshots/S-B-00-00.jpg and /dev/null differ diff --git a/screenshots/S-B-00-01.jpg b/screenshots/S-B-00-01.jpg deleted file mode 100644 index 7fcfab67..00000000 Binary files a/screenshots/S-B-00-01.jpg and /dev/null differ diff --git a/screenshots/S-B-00-02.jpg b/screenshots/S-B-00-02.jpg deleted file mode 100644 index 56bea59f..00000000 Binary files a/screenshots/S-B-00-02.jpg and /dev/null differ diff --git a/screenshots/S-B-00-03.jpg b/screenshots/S-B-00-03.jpg deleted file mode 100644 index efad89e3..00000000 Binary files a/screenshots/S-B-00-03.jpg and /dev/null differ diff --git a/screenshots/S-B-00-04.jpg b/screenshots/S-B-00-04.jpg deleted file mode 100644 index d805beeb..00000000 Binary files a/screenshots/S-B-00-04.jpg and /dev/null differ diff --git a/screenshots/S-B-00.jpg b/screenshots/S-B-00.jpg deleted file mode 100644 index 969b498a..00000000 Binary files a/screenshots/S-B-00.jpg and /dev/null differ diff --git a/screenshots/S-B-01-00.jpg b/screenshots/S-B-01-00.jpg deleted file mode 100644 index 1ea84f8c..00000000 Binary files a/screenshots/S-B-01-00.jpg and /dev/null differ diff --git a/screenshots/S-B-01-01.jpg b/screenshots/S-B-01-01.jpg deleted file mode 100644 index bb009545..00000000 Binary files a/screenshots/S-B-01-01.jpg and /dev/null differ diff --git a/screenshots/S-B-01-02.jpg b/screenshots/S-B-01-02.jpg deleted file mode 100644 index b74bf5e2..00000000 Binary files a/screenshots/S-B-01-02.jpg and /dev/null differ diff --git a/screenshots/S-B-01-03.jpg b/screenshots/S-B-01-03.jpg deleted file mode 100644 index d8cfc833..00000000 Binary files a/screenshots/S-B-01-03.jpg and /dev/null differ diff --git a/screenshots/S-B-01-04.jpg b/screenshots/S-B-01-04.jpg deleted file mode 100644 index 4ce4fd9a..00000000 Binary files a/screenshots/S-B-01-04.jpg and /dev/null differ diff --git a/screenshots/S-B-01.jpg b/screenshots/S-B-01.jpg deleted file mode 100644 index d11c6102..00000000 Binary files a/screenshots/S-B-01.jpg and /dev/null differ diff --git a/screenshots/S-B-02-00.jpg b/screenshots/S-B-02-00.jpg deleted file mode 100644 index e13f99ba..00000000 Binary files a/screenshots/S-B-02-00.jpg and /dev/null differ diff --git a/screenshots/S-B-02-01.jpg b/screenshots/S-B-02-01.jpg deleted file mode 100644 index 1bb2829b..00000000 Binary files a/screenshots/S-B-02-01.jpg and /dev/null differ diff --git a/screenshots/S-B-02-02.jpg b/screenshots/S-B-02-02.jpg deleted file mode 100644 index 56a6c552..00000000 Binary files a/screenshots/S-B-02-02.jpg and /dev/null differ diff --git a/screenshots/S-B-02-03.jpg b/screenshots/S-B-02-03.jpg deleted file mode 100644 index 24512981..00000000 Binary files a/screenshots/S-B-02-03.jpg and /dev/null differ diff --git a/screenshots/S-B-02-04.jpg b/screenshots/S-B-02-04.jpg deleted file mode 100644 index 1ae9d422..00000000 Binary files a/screenshots/S-B-02-04.jpg and /dev/null differ diff --git a/screenshots/S-B-02.jpg b/screenshots/S-B-02.jpg deleted file mode 100644 index 6060d45e..00000000 Binary files a/screenshots/S-B-02.jpg and /dev/null differ diff --git a/screenshots/S-B-03-00.jpg b/screenshots/S-B-03-00.jpg deleted file mode 100644 index 1c24d63e..00000000 Binary files a/screenshots/S-B-03-00.jpg and /dev/null differ diff --git a/screenshots/S-B-03-01.jpg b/screenshots/S-B-03-01.jpg deleted file mode 100644 index ac92f96f..00000000 Binary files a/screenshots/S-B-03-01.jpg and /dev/null differ diff --git a/screenshots/S-B-03-02.jpg b/screenshots/S-B-03-02.jpg deleted file mode 100644 index f37b70da..00000000 Binary files a/screenshots/S-B-03-02.jpg and /dev/null differ diff --git a/screenshots/S-B-03-03.jpg b/screenshots/S-B-03-03.jpg deleted file mode 100644 index 7af17291..00000000 Binary files a/screenshots/S-B-03-03.jpg and /dev/null differ diff --git a/screenshots/S-B-03-04.jpg b/screenshots/S-B-03-04.jpg deleted file mode 100644 index 4507c730..00000000 Binary files a/screenshots/S-B-03-04.jpg and /dev/null differ diff --git a/screenshots/S-B-03.jpg b/screenshots/S-B-03.jpg deleted file mode 100644 index 5cc2786c..00000000 Binary files a/screenshots/S-B-03.jpg and /dev/null differ diff --git a/screenshots/S-B-04-00.jpg b/screenshots/S-B-04-00.jpg deleted file mode 100644 index 93ff3ee9..00000000 Binary files a/screenshots/S-B-04-00.jpg and /dev/null differ diff --git a/screenshots/S-B-04-01.jpg b/screenshots/S-B-04-01.jpg deleted file mode 100644 index de4b756a..00000000 Binary files a/screenshots/S-B-04-01.jpg and /dev/null differ diff --git a/screenshots/S-B-04-02.jpg b/screenshots/S-B-04-02.jpg deleted file mode 100644 index ac5865b6..00000000 Binary files a/screenshots/S-B-04-02.jpg and /dev/null differ diff --git a/screenshots/S-B-04-03.jpg b/screenshots/S-B-04-03.jpg deleted file mode 100644 index 4648786c..00000000 Binary files a/screenshots/S-B-04-03.jpg and /dev/null differ diff --git a/screenshots/S-B-04-04.jpg b/screenshots/S-B-04-04.jpg deleted file mode 100644 index fd7bbc46..00000000 Binary files a/screenshots/S-B-04-04.jpg and /dev/null differ diff --git a/screenshots/S-B-04.jpg b/screenshots/S-B-04.jpg deleted file mode 100644 index ecaa4249..00000000 Binary files a/screenshots/S-B-04.jpg and /dev/null differ diff --git a/screenshots/S-B-05-00.jpg b/screenshots/S-B-05-00.jpg deleted file mode 100644 index bfb9ec6c..00000000 Binary files a/screenshots/S-B-05-00.jpg and /dev/null differ diff --git a/screenshots/S-B-05-01.jpg b/screenshots/S-B-05-01.jpg deleted file mode 100644 index 3eb12c7b..00000000 Binary files a/screenshots/S-B-05-01.jpg and /dev/null differ diff --git a/screenshots/S-B-05-02.jpg b/screenshots/S-B-05-02.jpg deleted file mode 100644 index 8949b853..00000000 Binary files a/screenshots/S-B-05-02.jpg and /dev/null differ diff --git a/screenshots/S-B-05-03.jpg b/screenshots/S-B-05-03.jpg deleted file mode 100644 index e2e6e038..00000000 Binary files a/screenshots/S-B-05-03.jpg and /dev/null differ diff --git a/screenshots/S-B-05-04.jpg b/screenshots/S-B-05-04.jpg deleted file mode 100644 index 67f553f9..00000000 Binary files a/screenshots/S-B-05-04.jpg and /dev/null differ diff --git a/screenshots/S-B-05.jpg b/screenshots/S-B-05.jpg deleted file mode 100644 index da9edff7..00000000 Binary files a/screenshots/S-B-05.jpg and /dev/null differ diff --git a/screenshots/S-B-06-00.jpg b/screenshots/S-B-06-00.jpg deleted file mode 100644 index 7f378c1d..00000000 Binary files a/screenshots/S-B-06-00.jpg and /dev/null differ diff --git a/screenshots/S-B-06-01.jpg b/screenshots/S-B-06-01.jpg deleted file mode 100644 index 69b35cf0..00000000 Binary files a/screenshots/S-B-06-01.jpg and /dev/null differ diff --git a/screenshots/S-B-06-02.jpg b/screenshots/S-B-06-02.jpg deleted file mode 100644 index ab1a231c..00000000 Binary files a/screenshots/S-B-06-02.jpg and /dev/null differ diff --git a/screenshots/S-B-06-03.jpg b/screenshots/S-B-06-03.jpg deleted file mode 100644 index 25665842..00000000 Binary files a/screenshots/S-B-06-03.jpg and /dev/null differ diff --git a/screenshots/S-B-06-04.jpg b/screenshots/S-B-06-04.jpg deleted file mode 100644 index a3238bac..00000000 Binary files a/screenshots/S-B-06-04.jpg and /dev/null differ diff --git a/screenshots/S-B-06.jpg b/screenshots/S-B-06.jpg deleted file mode 100644 index 16fedec2..00000000 Binary files a/screenshots/S-B-06.jpg and /dev/null differ diff --git a/screenshots/S-B-07-00.jpg b/screenshots/S-B-07-00.jpg deleted file mode 100644 index 6ff8ea73..00000000 Binary files a/screenshots/S-B-07-00.jpg and /dev/null differ diff --git a/screenshots/S-B-07-01.jpg b/screenshots/S-B-07-01.jpg deleted file mode 100644 index 8850f9bb..00000000 Binary files a/screenshots/S-B-07-01.jpg and /dev/null differ diff --git a/screenshots/S-B-07-02.jpg b/screenshots/S-B-07-02.jpg deleted file mode 100644 index aa4d2eb6..00000000 Binary files a/screenshots/S-B-07-02.jpg and /dev/null differ diff --git a/screenshots/S-B-07-03.jpg b/screenshots/S-B-07-03.jpg deleted file mode 100644 index a377912b..00000000 Binary files a/screenshots/S-B-07-03.jpg and /dev/null differ diff --git a/screenshots/S-B-07-04.jpg b/screenshots/S-B-07-04.jpg deleted file mode 100644 index a2a8dc94..00000000 Binary files a/screenshots/S-B-07-04.jpg and /dev/null differ diff --git a/screenshots/S-B-07.jpg b/screenshots/S-B-07.jpg deleted file mode 100644 index f6f6be17..00000000 Binary files a/screenshots/S-B-07.jpg and /dev/null differ diff --git a/screenshots/S-B-08-00.jpg b/screenshots/S-B-08-00.jpg deleted file mode 100644 index ba9f54ef..00000000 Binary files a/screenshots/S-B-08-00.jpg and /dev/null differ diff --git a/screenshots/S-B-08-01.jpg b/screenshots/S-B-08-01.jpg deleted file mode 100644 index 30aa8cd7..00000000 Binary files a/screenshots/S-B-08-01.jpg and /dev/null differ diff --git a/screenshots/S-B-08-02.jpg b/screenshots/S-B-08-02.jpg deleted file mode 100644 index 1539e612..00000000 Binary files a/screenshots/S-B-08-02.jpg and /dev/null differ diff --git a/screenshots/S-B-08-03.jpg b/screenshots/S-B-08-03.jpg deleted file mode 100644 index b00eed8b..00000000 Binary files a/screenshots/S-B-08-03.jpg and /dev/null differ diff --git a/screenshots/S-B-08-04.jpg b/screenshots/S-B-08-04.jpg deleted file mode 100644 index 3856bf9a..00000000 Binary files a/screenshots/S-B-08-04.jpg and /dev/null differ diff --git a/screenshots/S-B-08.jpg b/screenshots/S-B-08.jpg deleted file mode 100644 index 32523b47..00000000 Binary files a/screenshots/S-B-08.jpg and /dev/null differ diff --git a/screenshots/S-B-09-00.jpg b/screenshots/S-B-09-00.jpg deleted file mode 100644 index ed9dc695..00000000 Binary files a/screenshots/S-B-09-00.jpg and /dev/null differ diff --git a/screenshots/S-B-09-01.jpg b/screenshots/S-B-09-01.jpg deleted file mode 100644 index 185a5449..00000000 Binary files a/screenshots/S-B-09-01.jpg and /dev/null differ diff --git a/screenshots/S-B-09-02.jpg b/screenshots/S-B-09-02.jpg deleted file mode 100644 index 915299d3..00000000 Binary files a/screenshots/S-B-09-02.jpg and /dev/null differ diff --git a/screenshots/S-B-09-03.jpg b/screenshots/S-B-09-03.jpg deleted file mode 100644 index 6a679164..00000000 Binary files a/screenshots/S-B-09-03.jpg and /dev/null differ diff --git a/screenshots/S-B-09-04.jpg b/screenshots/S-B-09-04.jpg deleted file mode 100644 index e2467097..00000000 Binary files a/screenshots/S-B-09-04.jpg and /dev/null differ diff --git a/screenshots/S-B-09.jpg b/screenshots/S-B-09.jpg deleted file mode 100644 index a13029a4..00000000 Binary files a/screenshots/S-B-09.jpg and /dev/null differ diff --git a/screenshots/S-B-10-00.jpg b/screenshots/S-B-10-00.jpg deleted file mode 100644 index 141ff799..00000000 Binary files a/screenshots/S-B-10-00.jpg and /dev/null differ diff --git a/screenshots/S-B-10-01.jpg b/screenshots/S-B-10-01.jpg deleted file mode 100644 index e04ed88f..00000000 Binary files a/screenshots/S-B-10-01.jpg and /dev/null differ diff --git a/screenshots/S-B-10-02.jpg b/screenshots/S-B-10-02.jpg deleted file mode 100644 index 1743e2da..00000000 Binary files a/screenshots/S-B-10-02.jpg and /dev/null differ diff --git a/screenshots/S-B-10-03.jpg b/screenshots/S-B-10-03.jpg deleted file mode 100644 index 254c1d50..00000000 Binary files a/screenshots/S-B-10-03.jpg and /dev/null differ diff --git a/screenshots/S-B-10-04.jpg b/screenshots/S-B-10-04.jpg deleted file mode 100644 index d32ed777..00000000 Binary files a/screenshots/S-B-10-04.jpg and /dev/null differ diff --git a/screenshots/S-B-10.jpg b/screenshots/S-B-10.jpg deleted file mode 100644 index 38180b3b..00000000 Binary files a/screenshots/S-B-10.jpg and /dev/null differ diff --git a/screenshots/S-B-11-00.jpg b/screenshots/S-B-11-00.jpg deleted file mode 100644 index 6f639d85..00000000 Binary files a/screenshots/S-B-11-00.jpg and /dev/null differ diff --git a/screenshots/S-B-11-01.jpg b/screenshots/S-B-11-01.jpg deleted file mode 100644 index 18f685e1..00000000 Binary files a/screenshots/S-B-11-01.jpg and /dev/null differ diff --git a/screenshots/S-B-11-02.jpg b/screenshots/S-B-11-02.jpg deleted file mode 100644 index 31ea4080..00000000 Binary files a/screenshots/S-B-11-02.jpg and /dev/null differ diff --git a/screenshots/S-B-11-03.jpg b/screenshots/S-B-11-03.jpg deleted file mode 100644 index 4eb5bd97..00000000 Binary files a/screenshots/S-B-11-03.jpg and /dev/null differ diff --git a/screenshots/S-B-11-04.jpg b/screenshots/S-B-11-04.jpg deleted file mode 100644 index bcbee289..00000000 Binary files a/screenshots/S-B-11-04.jpg and /dev/null differ diff --git a/screenshots/S-B-11.jpg b/screenshots/S-B-11.jpg deleted file mode 100644 index a58aab49..00000000 Binary files a/screenshots/S-B-11.jpg and /dev/null differ diff --git a/screenshots/S-B-12-00.jpg b/screenshots/S-B-12-00.jpg deleted file mode 100644 index db41a17f..00000000 Binary files a/screenshots/S-B-12-00.jpg and /dev/null differ diff --git a/screenshots/S-B-12-01.jpg b/screenshots/S-B-12-01.jpg deleted file mode 100644 index 1bd6d35f..00000000 Binary files a/screenshots/S-B-12-01.jpg and /dev/null differ diff --git a/screenshots/S-B-12-02.jpg b/screenshots/S-B-12-02.jpg deleted file mode 100644 index 2a83ac9d..00000000 Binary files a/screenshots/S-B-12-02.jpg and /dev/null differ diff --git a/screenshots/S-B-12-03.jpg b/screenshots/S-B-12-03.jpg deleted file mode 100644 index a9292209..00000000 Binary files a/screenshots/S-B-12-03.jpg and /dev/null differ diff --git a/screenshots/S-B-12-04.jpg b/screenshots/S-B-12-04.jpg deleted file mode 100644 index 7c7be597..00000000 Binary files a/screenshots/S-B-12-04.jpg and /dev/null differ diff --git a/screenshots/S-B-12.jpg b/screenshots/S-B-12.jpg deleted file mode 100644 index 1e419c29..00000000 Binary files a/screenshots/S-B-12.jpg and /dev/null differ diff --git a/screenshots/S-B-13-00.jpg b/screenshots/S-B-13-00.jpg deleted file mode 100644 index e51edef6..00000000 Binary files a/screenshots/S-B-13-00.jpg and /dev/null differ diff --git a/screenshots/S-B-13-01.jpg b/screenshots/S-B-13-01.jpg deleted file mode 100644 index 64476a41..00000000 Binary files a/screenshots/S-B-13-01.jpg and /dev/null differ diff --git a/screenshots/S-B-13-02.jpg b/screenshots/S-B-13-02.jpg deleted file mode 100644 index b4f8ef81..00000000 Binary files a/screenshots/S-B-13-02.jpg and /dev/null differ diff --git a/screenshots/S-B-13-03.jpg b/screenshots/S-B-13-03.jpg deleted file mode 100644 index 7fa8f4ab..00000000 Binary files a/screenshots/S-B-13-03.jpg and /dev/null differ diff --git a/screenshots/S-B-13-04.jpg b/screenshots/S-B-13-04.jpg deleted file mode 100644 index 11281e77..00000000 Binary files a/screenshots/S-B-13-04.jpg and /dev/null differ diff --git a/screenshots/S-B-13.jpg b/screenshots/S-B-13.jpg deleted file mode 100644 index 8a787d21..00000000 Binary files a/screenshots/S-B-13.jpg and /dev/null differ diff --git a/screenshots/S-B-14-00.jpg b/screenshots/S-B-14-00.jpg deleted file mode 100644 index 2f8b2fcb..00000000 Binary files a/screenshots/S-B-14-00.jpg and /dev/null differ diff --git a/screenshots/S-B-14-01.jpg b/screenshots/S-B-14-01.jpg deleted file mode 100644 index e1e83ef4..00000000 Binary files a/screenshots/S-B-14-01.jpg and /dev/null differ diff --git a/screenshots/S-B-14-02.jpg b/screenshots/S-B-14-02.jpg deleted file mode 100644 index 66732251..00000000 Binary files a/screenshots/S-B-14-02.jpg and /dev/null differ diff --git a/screenshots/S-B-14-03.jpg b/screenshots/S-B-14-03.jpg deleted file mode 100644 index d26128f0..00000000 Binary files a/screenshots/S-B-14-03.jpg and /dev/null differ diff --git a/screenshots/S-B-14-04.jpg b/screenshots/S-B-14-04.jpg deleted file mode 100644 index 3221544b..00000000 Binary files a/screenshots/S-B-14-04.jpg and /dev/null differ diff --git a/screenshots/S-B-14.jpg b/screenshots/S-B-14.jpg deleted file mode 100644 index d8f653ee..00000000 Binary files a/screenshots/S-B-14.jpg and /dev/null differ diff --git a/screenshots/S-B-15-00.jpg b/screenshots/S-B-15-00.jpg deleted file mode 100644 index 71eb0b2b..00000000 Binary files a/screenshots/S-B-15-00.jpg and /dev/null differ diff --git a/screenshots/S-B-15-01.jpg b/screenshots/S-B-15-01.jpg deleted file mode 100644 index bf5193ce..00000000 Binary files a/screenshots/S-B-15-01.jpg and /dev/null differ diff --git a/screenshots/S-B-15-02.jpg b/screenshots/S-B-15-02.jpg deleted file mode 100644 index 2fde971e..00000000 Binary files a/screenshots/S-B-15-02.jpg and /dev/null differ diff --git a/screenshots/S-B-15-03.jpg b/screenshots/S-B-15-03.jpg deleted file mode 100644 index 39bb1b21..00000000 Binary files a/screenshots/S-B-15-03.jpg and /dev/null differ diff --git a/screenshots/S-B-15-04.jpg b/screenshots/S-B-15-04.jpg deleted file mode 100644 index 7b2e6c63..00000000 Binary files a/screenshots/S-B-15-04.jpg and /dev/null differ diff --git a/screenshots/S-B-15.jpg b/screenshots/S-B-15.jpg deleted file mode 100644 index 52476e21..00000000 Binary files a/screenshots/S-B-15.jpg and /dev/null differ diff --git a/screenshots/S-B-16-00.jpg b/screenshots/S-B-16-00.jpg deleted file mode 100644 index 81a50813..00000000 Binary files a/screenshots/S-B-16-00.jpg and /dev/null differ diff --git a/screenshots/S-B-16-01.jpg b/screenshots/S-B-16-01.jpg deleted file mode 100644 index 70853614..00000000 Binary files a/screenshots/S-B-16-01.jpg and /dev/null differ diff --git a/screenshots/S-B-16-02.jpg b/screenshots/S-B-16-02.jpg deleted file mode 100644 index 801a24df..00000000 Binary files a/screenshots/S-B-16-02.jpg and /dev/null differ diff --git a/screenshots/S-B-16-03.jpg b/screenshots/S-B-16-03.jpg deleted file mode 100644 index aa0e8bdf..00000000 Binary files a/screenshots/S-B-16-03.jpg and /dev/null differ diff --git a/screenshots/S-B-16-04.jpg b/screenshots/S-B-16-04.jpg deleted file mode 100644 index e5c95226..00000000 Binary files a/screenshots/S-B-16-04.jpg and /dev/null differ diff --git a/screenshots/S-B-16.jpg b/screenshots/S-B-16.jpg deleted file mode 100644 index fc6fcc44..00000000 Binary files a/screenshots/S-B-16.jpg and /dev/null differ diff --git a/screenshots/S-B-17-00.jpg b/screenshots/S-B-17-00.jpg deleted file mode 100644 index 266eb19f..00000000 Binary files a/screenshots/S-B-17-00.jpg and /dev/null differ diff --git a/screenshots/S-B-17-01.jpg b/screenshots/S-B-17-01.jpg deleted file mode 100644 index f2565871..00000000 Binary files a/screenshots/S-B-17-01.jpg and /dev/null differ diff --git a/screenshots/S-B-17-02.jpg b/screenshots/S-B-17-02.jpg deleted file mode 100644 index eeac95bd..00000000 Binary files a/screenshots/S-B-17-02.jpg and /dev/null differ diff --git a/screenshots/S-B-17-03.jpg b/screenshots/S-B-17-03.jpg deleted file mode 100644 index 5ffa7b51..00000000 Binary files a/screenshots/S-B-17-03.jpg and /dev/null differ diff --git a/screenshots/S-B-17-04.jpg b/screenshots/S-B-17-04.jpg deleted file mode 100644 index 181852e6..00000000 Binary files a/screenshots/S-B-17-04.jpg and /dev/null differ diff --git a/screenshots/S-B-17.jpg b/screenshots/S-B-17.jpg deleted file mode 100644 index 7fda5bd3..00000000 Binary files a/screenshots/S-B-17.jpg and /dev/null differ diff --git a/screenshots/S-B-18-00.jpg b/screenshots/S-B-18-00.jpg deleted file mode 100644 index 70c66092..00000000 Binary files a/screenshots/S-B-18-00.jpg and /dev/null differ diff --git a/screenshots/S-B-18-01.jpg b/screenshots/S-B-18-01.jpg deleted file mode 100644 index b8ecb553..00000000 Binary files a/screenshots/S-B-18-01.jpg and /dev/null differ diff --git a/screenshots/S-B-18-02.jpg b/screenshots/S-B-18-02.jpg deleted file mode 100644 index 91afffea..00000000 Binary files a/screenshots/S-B-18-02.jpg and /dev/null differ diff --git a/screenshots/S-B-18-03.jpg b/screenshots/S-B-18-03.jpg deleted file mode 100644 index 0f739b35..00000000 Binary files a/screenshots/S-B-18-03.jpg and /dev/null differ diff --git a/screenshots/S-B-18-04.jpg b/screenshots/S-B-18-04.jpg deleted file mode 100644 index f0772202..00000000 Binary files a/screenshots/S-B-18-04.jpg and /dev/null differ diff --git a/screenshots/S-B-18.jpg b/screenshots/S-B-18.jpg deleted file mode 100644 index 15f421ba..00000000 Binary files a/screenshots/S-B-18.jpg and /dev/null differ diff --git a/screenshots/S-B-19-00.jpg b/screenshots/S-B-19-00.jpg deleted file mode 100644 index 93c8606e..00000000 Binary files a/screenshots/S-B-19-00.jpg and /dev/null differ diff --git a/screenshots/S-B-19-01.jpg b/screenshots/S-B-19-01.jpg deleted file mode 100644 index c5813d66..00000000 Binary files a/screenshots/S-B-19-01.jpg and /dev/null differ diff --git a/screenshots/S-B-19-02.jpg b/screenshots/S-B-19-02.jpg deleted file mode 100644 index 97ef4c2f..00000000 Binary files a/screenshots/S-B-19-02.jpg and /dev/null differ diff --git a/screenshots/S-B-19-03.jpg b/screenshots/S-B-19-03.jpg deleted file mode 100644 index e9399878..00000000 Binary files a/screenshots/S-B-19-03.jpg and /dev/null differ diff --git a/screenshots/S-B-19-04.jpg b/screenshots/S-B-19-04.jpg deleted file mode 100644 index aaa0734a..00000000 Binary files a/screenshots/S-B-19-04.jpg and /dev/null differ diff --git a/screenshots/S-B-19.jpg b/screenshots/S-B-19.jpg deleted file mode 100644 index cffd3584..00000000 Binary files a/screenshots/S-B-19.jpg and /dev/null differ diff --git a/screenshots/S-C-00-00.jpg b/screenshots/S-C-00-00.jpg deleted file mode 100644 index c75c1760..00000000 Binary files a/screenshots/S-C-00-00.jpg and /dev/null differ diff --git a/screenshots/S-C-00-01.jpg b/screenshots/S-C-00-01.jpg deleted file mode 100644 index 0cf9168e..00000000 Binary files a/screenshots/S-C-00-01.jpg and /dev/null differ diff --git a/screenshots/S-C-00-02.jpg b/screenshots/S-C-00-02.jpg deleted file mode 100644 index 6ab963a9..00000000 Binary files a/screenshots/S-C-00-02.jpg and /dev/null differ diff --git a/screenshots/S-C-00-03.jpg b/screenshots/S-C-00-03.jpg deleted file mode 100644 index 20bd1c9d..00000000 Binary files a/screenshots/S-C-00-03.jpg and /dev/null differ diff --git a/screenshots/S-C-00-04.jpg b/screenshots/S-C-00-04.jpg deleted file mode 100644 index ec7b6f79..00000000 Binary files a/screenshots/S-C-00-04.jpg and /dev/null differ diff --git a/screenshots/S-C-00.jpg b/screenshots/S-C-00.jpg deleted file mode 100644 index da2638f0..00000000 Binary files a/screenshots/S-C-00.jpg and /dev/null differ diff --git a/screenshots/S-C-01-00.jpg b/screenshots/S-C-01-00.jpg deleted file mode 100644 index 3b7fcfdd..00000000 Binary files a/screenshots/S-C-01-00.jpg and /dev/null differ diff --git a/screenshots/S-C-01-01.jpg b/screenshots/S-C-01-01.jpg deleted file mode 100644 index 13a13c8f..00000000 Binary files a/screenshots/S-C-01-01.jpg and /dev/null differ diff --git a/screenshots/S-C-01-02.jpg b/screenshots/S-C-01-02.jpg deleted file mode 100644 index 810c70ba..00000000 Binary files a/screenshots/S-C-01-02.jpg and /dev/null differ diff --git a/screenshots/S-C-01-03.jpg b/screenshots/S-C-01-03.jpg deleted file mode 100644 index 35b84b81..00000000 Binary files a/screenshots/S-C-01-03.jpg and /dev/null differ diff --git a/screenshots/S-C-01-04.jpg b/screenshots/S-C-01-04.jpg deleted file mode 100644 index a24aaaeb..00000000 Binary files a/screenshots/S-C-01-04.jpg and /dev/null differ diff --git a/screenshots/S-C-01.jpg b/screenshots/S-C-01.jpg deleted file mode 100644 index 7b1f25fe..00000000 Binary files a/screenshots/S-C-01.jpg and /dev/null differ diff --git a/screenshots/S-C-02-00.jpg b/screenshots/S-C-02-00.jpg deleted file mode 100644 index bc18d55f..00000000 Binary files a/screenshots/S-C-02-00.jpg and /dev/null differ diff --git a/screenshots/S-C-02-01.jpg b/screenshots/S-C-02-01.jpg deleted file mode 100644 index db8b652a..00000000 Binary files a/screenshots/S-C-02-01.jpg and /dev/null differ diff --git a/screenshots/S-C-02-02.jpg b/screenshots/S-C-02-02.jpg deleted file mode 100644 index 8dcfd952..00000000 Binary files a/screenshots/S-C-02-02.jpg and /dev/null differ diff --git a/screenshots/S-C-02-03.jpg b/screenshots/S-C-02-03.jpg deleted file mode 100644 index a57238c5..00000000 Binary files a/screenshots/S-C-02-03.jpg and /dev/null differ diff --git a/screenshots/S-C-02-04.jpg b/screenshots/S-C-02-04.jpg deleted file mode 100644 index 04e0e8ba..00000000 Binary files a/screenshots/S-C-02-04.jpg and /dev/null differ diff --git a/screenshots/S-C-02.jpg b/screenshots/S-C-02.jpg deleted file mode 100644 index cd0f58ac..00000000 Binary files a/screenshots/S-C-02.jpg and /dev/null differ diff --git a/screenshots/S-C-03-00.jpg b/screenshots/S-C-03-00.jpg deleted file mode 100644 index 1dac75f1..00000000 Binary files a/screenshots/S-C-03-00.jpg and /dev/null differ diff --git a/screenshots/S-C-03-01.jpg b/screenshots/S-C-03-01.jpg deleted file mode 100644 index 7e29e718..00000000 Binary files a/screenshots/S-C-03-01.jpg and /dev/null differ diff --git a/screenshots/S-C-03-02.jpg b/screenshots/S-C-03-02.jpg deleted file mode 100644 index 763dfed3..00000000 Binary files a/screenshots/S-C-03-02.jpg and /dev/null differ diff --git a/screenshots/S-C-03-03.jpg b/screenshots/S-C-03-03.jpg deleted file mode 100644 index 14ae9cd5..00000000 Binary files a/screenshots/S-C-03-03.jpg and /dev/null differ diff --git a/screenshots/S-C-03-04.jpg b/screenshots/S-C-03-04.jpg deleted file mode 100644 index db455f9a..00000000 Binary files a/screenshots/S-C-03-04.jpg and /dev/null differ diff --git a/screenshots/S-C-03.jpg b/screenshots/S-C-03.jpg deleted file mode 100644 index 9cda310f..00000000 Binary files a/screenshots/S-C-03.jpg and /dev/null differ diff --git a/screenshots/S-C-04-00.jpg b/screenshots/S-C-04-00.jpg deleted file mode 100644 index 402ea06e..00000000 Binary files a/screenshots/S-C-04-00.jpg and /dev/null differ diff --git a/screenshots/S-C-04-01.jpg b/screenshots/S-C-04-01.jpg deleted file mode 100644 index 15b54149..00000000 Binary files a/screenshots/S-C-04-01.jpg and /dev/null differ diff --git a/screenshots/S-C-04-02.jpg b/screenshots/S-C-04-02.jpg deleted file mode 100644 index b69a6291..00000000 Binary files a/screenshots/S-C-04-02.jpg and /dev/null differ diff --git a/screenshots/S-C-04-03.jpg b/screenshots/S-C-04-03.jpg deleted file mode 100644 index 852afa9e..00000000 Binary files a/screenshots/S-C-04-03.jpg and /dev/null differ diff --git a/screenshots/S-C-04-04.jpg b/screenshots/S-C-04-04.jpg deleted file mode 100644 index bd08f02e..00000000 Binary files a/screenshots/S-C-04-04.jpg and /dev/null differ diff --git a/screenshots/S-C-04.jpg b/screenshots/S-C-04.jpg deleted file mode 100644 index 485c19fe..00000000 Binary files a/screenshots/S-C-04.jpg and /dev/null differ diff --git a/screenshots/S-C-05-00.jpg b/screenshots/S-C-05-00.jpg deleted file mode 100644 index 2dd3b51b..00000000 Binary files a/screenshots/S-C-05-00.jpg and /dev/null differ diff --git a/screenshots/S-C-05-01.jpg b/screenshots/S-C-05-01.jpg deleted file mode 100644 index 681199f2..00000000 Binary files a/screenshots/S-C-05-01.jpg and /dev/null differ diff --git a/screenshots/S-C-05-02.jpg b/screenshots/S-C-05-02.jpg deleted file mode 100644 index ba68ab08..00000000 Binary files a/screenshots/S-C-05-02.jpg and /dev/null differ diff --git a/screenshots/S-C-05-03.jpg b/screenshots/S-C-05-03.jpg deleted file mode 100644 index 30193d27..00000000 Binary files a/screenshots/S-C-05-03.jpg and /dev/null differ diff --git a/screenshots/S-C-05-04.jpg b/screenshots/S-C-05-04.jpg deleted file mode 100644 index 88350771..00000000 Binary files a/screenshots/S-C-05-04.jpg and /dev/null differ diff --git a/screenshots/S-C-05.jpg b/screenshots/S-C-05.jpg deleted file mode 100644 index af270436..00000000 Binary files a/screenshots/S-C-05.jpg and /dev/null differ diff --git a/screenshots/S-C-06-00.jpg b/screenshots/S-C-06-00.jpg deleted file mode 100644 index 484d282f..00000000 Binary files a/screenshots/S-C-06-00.jpg and /dev/null differ diff --git a/screenshots/S-C-06-01.jpg b/screenshots/S-C-06-01.jpg deleted file mode 100644 index 28e8f167..00000000 Binary files a/screenshots/S-C-06-01.jpg and /dev/null differ diff --git a/screenshots/S-C-06-02.jpg b/screenshots/S-C-06-02.jpg deleted file mode 100644 index 822ddc2b..00000000 Binary files a/screenshots/S-C-06-02.jpg and /dev/null differ diff --git a/screenshots/S-C-06-03.jpg b/screenshots/S-C-06-03.jpg deleted file mode 100644 index 63641eef..00000000 Binary files a/screenshots/S-C-06-03.jpg and /dev/null differ diff --git a/screenshots/S-C-06-04.jpg b/screenshots/S-C-06-04.jpg deleted file mode 100644 index fc3f7899..00000000 Binary files a/screenshots/S-C-06-04.jpg and /dev/null differ diff --git a/screenshots/S-C-06.jpg b/screenshots/S-C-06.jpg deleted file mode 100644 index c69b8642..00000000 Binary files a/screenshots/S-C-06.jpg and /dev/null differ diff --git a/screenshots/S-C-07-00.jpg b/screenshots/S-C-07-00.jpg deleted file mode 100644 index 571de06e..00000000 Binary files a/screenshots/S-C-07-00.jpg and /dev/null differ diff --git a/screenshots/S-C-07-01.jpg b/screenshots/S-C-07-01.jpg deleted file mode 100644 index 2db84fbf..00000000 Binary files a/screenshots/S-C-07-01.jpg and /dev/null differ diff --git a/screenshots/S-C-07-02.jpg b/screenshots/S-C-07-02.jpg deleted file mode 100644 index e4595b3d..00000000 Binary files a/screenshots/S-C-07-02.jpg and /dev/null differ diff --git a/screenshots/S-C-07-03.jpg b/screenshots/S-C-07-03.jpg deleted file mode 100644 index 21deda7a..00000000 Binary files a/screenshots/S-C-07-03.jpg and /dev/null differ diff --git a/screenshots/S-C-07-04.jpg b/screenshots/S-C-07-04.jpg deleted file mode 100644 index 17f0ae7f..00000000 Binary files a/screenshots/S-C-07-04.jpg and /dev/null differ diff --git a/screenshots/S-C-07.jpg b/screenshots/S-C-07.jpg deleted file mode 100644 index 7564b839..00000000 Binary files a/screenshots/S-C-07.jpg and /dev/null differ diff --git a/screenshots/S-C-08-00.jpg b/screenshots/S-C-08-00.jpg deleted file mode 100644 index cb78b755..00000000 Binary files a/screenshots/S-C-08-00.jpg and /dev/null differ diff --git a/screenshots/S-C-08-01.jpg b/screenshots/S-C-08-01.jpg deleted file mode 100644 index dba01858..00000000 Binary files a/screenshots/S-C-08-01.jpg and /dev/null differ diff --git a/screenshots/S-C-08-02.jpg b/screenshots/S-C-08-02.jpg deleted file mode 100644 index cd30cc99..00000000 Binary files a/screenshots/S-C-08-02.jpg and /dev/null differ diff --git a/screenshots/S-C-08-03.jpg b/screenshots/S-C-08-03.jpg deleted file mode 100644 index a94c0eee..00000000 Binary files a/screenshots/S-C-08-03.jpg and /dev/null differ diff --git a/screenshots/S-C-08-04.jpg b/screenshots/S-C-08-04.jpg deleted file mode 100644 index 0fdca9ab..00000000 Binary files a/screenshots/S-C-08-04.jpg and /dev/null differ diff --git a/screenshots/S-C-08.jpg b/screenshots/S-C-08.jpg deleted file mode 100644 index 3389ed7f..00000000 Binary files a/screenshots/S-C-08.jpg and /dev/null differ diff --git a/screenshots/S-C-09-00.jpg b/screenshots/S-C-09-00.jpg deleted file mode 100644 index dbb8ca2b..00000000 Binary files a/screenshots/S-C-09-00.jpg and /dev/null differ diff --git a/screenshots/S-C-09-01.jpg b/screenshots/S-C-09-01.jpg deleted file mode 100644 index aeb57645..00000000 Binary files a/screenshots/S-C-09-01.jpg and /dev/null differ diff --git a/screenshots/S-C-09-02.jpg b/screenshots/S-C-09-02.jpg deleted file mode 100644 index bfb18d78..00000000 Binary files a/screenshots/S-C-09-02.jpg and /dev/null differ diff --git a/screenshots/S-C-09-03.jpg b/screenshots/S-C-09-03.jpg deleted file mode 100644 index 3b5a1ec1..00000000 Binary files a/screenshots/S-C-09-03.jpg and /dev/null differ diff --git a/screenshots/S-C-09-04.jpg b/screenshots/S-C-09-04.jpg deleted file mode 100644 index da356c06..00000000 Binary files a/screenshots/S-C-09-04.jpg and /dev/null differ diff --git a/screenshots/S-C-09.jpg b/screenshots/S-C-09.jpg deleted file mode 100644 index 72af0ee6..00000000 Binary files a/screenshots/S-C-09.jpg and /dev/null differ diff --git a/screenshots/S-C-10-00.jpg b/screenshots/S-C-10-00.jpg deleted file mode 100644 index 404ff59f..00000000 Binary files a/screenshots/S-C-10-00.jpg and /dev/null differ diff --git a/screenshots/S-C-10-01.jpg b/screenshots/S-C-10-01.jpg deleted file mode 100644 index 24677cdd..00000000 Binary files a/screenshots/S-C-10-01.jpg and /dev/null differ diff --git a/screenshots/S-C-10-02.jpg b/screenshots/S-C-10-02.jpg deleted file mode 100644 index 4476734b..00000000 Binary files a/screenshots/S-C-10-02.jpg and /dev/null differ diff --git a/screenshots/S-C-10-03.jpg b/screenshots/S-C-10-03.jpg deleted file mode 100644 index cd4d4bd8..00000000 Binary files a/screenshots/S-C-10-03.jpg and /dev/null differ diff --git a/screenshots/S-C-10-04.jpg b/screenshots/S-C-10-04.jpg deleted file mode 100644 index 7a54f46a..00000000 Binary files a/screenshots/S-C-10-04.jpg and /dev/null differ diff --git a/screenshots/S-C-10.jpg b/screenshots/S-C-10.jpg deleted file mode 100644 index 2dca1f5e..00000000 Binary files a/screenshots/S-C-10.jpg and /dev/null differ diff --git a/screenshots/S-C-11-00.jpg b/screenshots/S-C-11-00.jpg deleted file mode 100644 index 95f7d99b..00000000 Binary files a/screenshots/S-C-11-00.jpg and /dev/null differ diff --git a/screenshots/S-C-11-01.jpg b/screenshots/S-C-11-01.jpg deleted file mode 100644 index e61631bb..00000000 Binary files a/screenshots/S-C-11-01.jpg and /dev/null differ diff --git a/screenshots/S-C-11-02.jpg b/screenshots/S-C-11-02.jpg deleted file mode 100644 index 3886e70f..00000000 Binary files a/screenshots/S-C-11-02.jpg and /dev/null differ diff --git a/screenshots/S-C-11-03.jpg b/screenshots/S-C-11-03.jpg deleted file mode 100644 index d13de4a9..00000000 Binary files a/screenshots/S-C-11-03.jpg and /dev/null differ diff --git a/screenshots/S-C-11-04.jpg b/screenshots/S-C-11-04.jpg deleted file mode 100644 index 3d4d0716..00000000 Binary files a/screenshots/S-C-11-04.jpg and /dev/null differ diff --git a/screenshots/S-C-11.jpg b/screenshots/S-C-11.jpg deleted file mode 100644 index b4250702..00000000 Binary files a/screenshots/S-C-11.jpg and /dev/null differ diff --git a/screenshots/S-C-12-00.jpg b/screenshots/S-C-12-00.jpg deleted file mode 100644 index 1af64384..00000000 Binary files a/screenshots/S-C-12-00.jpg and /dev/null differ diff --git a/screenshots/S-C-12-01.jpg b/screenshots/S-C-12-01.jpg deleted file mode 100644 index 32097633..00000000 Binary files a/screenshots/S-C-12-01.jpg and /dev/null differ diff --git a/screenshots/S-C-12-02.jpg b/screenshots/S-C-12-02.jpg deleted file mode 100644 index 7294f0dc..00000000 Binary files a/screenshots/S-C-12-02.jpg and /dev/null differ diff --git a/screenshots/S-C-12-03.jpg b/screenshots/S-C-12-03.jpg deleted file mode 100644 index 0b78dc91..00000000 Binary files a/screenshots/S-C-12-03.jpg and /dev/null differ diff --git a/screenshots/S-C-12-04.jpg b/screenshots/S-C-12-04.jpg deleted file mode 100644 index c8388453..00000000 Binary files a/screenshots/S-C-12-04.jpg and /dev/null differ diff --git a/screenshots/S-C-12.jpg b/screenshots/S-C-12.jpg deleted file mode 100644 index bf0e8540..00000000 Binary files a/screenshots/S-C-12.jpg and /dev/null differ diff --git a/screenshots/S-C-13-00.jpg b/screenshots/S-C-13-00.jpg deleted file mode 100644 index 1a753eb9..00000000 Binary files a/screenshots/S-C-13-00.jpg and /dev/null differ diff --git a/screenshots/S-C-13-01.jpg b/screenshots/S-C-13-01.jpg deleted file mode 100644 index 17faac82..00000000 Binary files a/screenshots/S-C-13-01.jpg and /dev/null differ diff --git a/screenshots/S-C-13-02.jpg b/screenshots/S-C-13-02.jpg deleted file mode 100644 index 094fb111..00000000 Binary files a/screenshots/S-C-13-02.jpg and /dev/null differ diff --git a/screenshots/S-C-13-03.jpg b/screenshots/S-C-13-03.jpg deleted file mode 100644 index 57a1e195..00000000 Binary files a/screenshots/S-C-13-03.jpg and /dev/null differ diff --git a/screenshots/S-C-13-04.jpg b/screenshots/S-C-13-04.jpg deleted file mode 100644 index 741a0ea0..00000000 Binary files a/screenshots/S-C-13-04.jpg and /dev/null differ diff --git a/screenshots/S-C-13.jpg b/screenshots/S-C-13.jpg deleted file mode 100644 index 7030fa64..00000000 Binary files a/screenshots/S-C-13.jpg and /dev/null differ diff --git a/screenshots/S-C-14-00.jpg b/screenshots/S-C-14-00.jpg deleted file mode 100644 index 59b71dde..00000000 Binary files a/screenshots/S-C-14-00.jpg and /dev/null differ diff --git a/screenshots/S-C-14-01.jpg b/screenshots/S-C-14-01.jpg deleted file mode 100644 index 34b9114c..00000000 Binary files a/screenshots/S-C-14-01.jpg and /dev/null differ diff --git a/screenshots/S-C-14-02.jpg b/screenshots/S-C-14-02.jpg deleted file mode 100644 index 4985150d..00000000 Binary files a/screenshots/S-C-14-02.jpg and /dev/null differ diff --git a/screenshots/S-C-14-03.jpg b/screenshots/S-C-14-03.jpg deleted file mode 100644 index c0362319..00000000 Binary files a/screenshots/S-C-14-03.jpg and /dev/null differ diff --git a/screenshots/S-C-14-04.jpg b/screenshots/S-C-14-04.jpg deleted file mode 100644 index 6bf02e51..00000000 Binary files a/screenshots/S-C-14-04.jpg and /dev/null differ diff --git a/screenshots/S-C-14.jpg b/screenshots/S-C-14.jpg deleted file mode 100644 index 23ff7105..00000000 Binary files a/screenshots/S-C-14.jpg and /dev/null differ diff --git a/screenshots/S-C-15-00.jpg b/screenshots/S-C-15-00.jpg deleted file mode 100644 index f3423eee..00000000 Binary files a/screenshots/S-C-15-00.jpg and /dev/null differ diff --git a/screenshots/S-C-15-01.jpg b/screenshots/S-C-15-01.jpg deleted file mode 100644 index 34b8969d..00000000 Binary files a/screenshots/S-C-15-01.jpg and /dev/null differ diff --git a/screenshots/S-C-15-02.jpg b/screenshots/S-C-15-02.jpg deleted file mode 100644 index 8419e643..00000000 Binary files a/screenshots/S-C-15-02.jpg and /dev/null differ diff --git a/screenshots/S-C-15-03.jpg b/screenshots/S-C-15-03.jpg deleted file mode 100644 index dd04282a..00000000 Binary files a/screenshots/S-C-15-03.jpg and /dev/null differ diff --git a/screenshots/S-C-15-04.jpg b/screenshots/S-C-15-04.jpg deleted file mode 100644 index fa6e833f..00000000 Binary files a/screenshots/S-C-15-04.jpg and /dev/null differ diff --git a/screenshots/S-C-15.jpg b/screenshots/S-C-15.jpg deleted file mode 100644 index f69f67db..00000000 Binary files a/screenshots/S-C-15.jpg and /dev/null differ diff --git a/screenshots/S-C-16-00.jpg b/screenshots/S-C-16-00.jpg deleted file mode 100644 index 97f2376e..00000000 Binary files a/screenshots/S-C-16-00.jpg and /dev/null differ diff --git a/screenshots/S-C-16-01.jpg b/screenshots/S-C-16-01.jpg deleted file mode 100644 index 783b07df..00000000 Binary files a/screenshots/S-C-16-01.jpg and /dev/null differ diff --git a/screenshots/S-C-16-02.jpg b/screenshots/S-C-16-02.jpg deleted file mode 100644 index d7a2853a..00000000 Binary files a/screenshots/S-C-16-02.jpg and /dev/null differ diff --git a/screenshots/S-C-16-03.jpg b/screenshots/S-C-16-03.jpg deleted file mode 100644 index 6c838b89..00000000 Binary files a/screenshots/S-C-16-03.jpg and /dev/null differ diff --git a/screenshots/S-C-16-04.jpg b/screenshots/S-C-16-04.jpg deleted file mode 100644 index 80ec86e8..00000000 Binary files a/screenshots/S-C-16-04.jpg and /dev/null differ diff --git a/screenshots/S-C-16.jpg b/screenshots/S-C-16.jpg deleted file mode 100644 index e7278c32..00000000 Binary files a/screenshots/S-C-16.jpg and /dev/null differ diff --git a/screenshots/S-C-17-00.jpg b/screenshots/S-C-17-00.jpg deleted file mode 100644 index a1895107..00000000 Binary files a/screenshots/S-C-17-00.jpg and /dev/null differ diff --git a/screenshots/S-C-17-01.jpg b/screenshots/S-C-17-01.jpg deleted file mode 100644 index 436ec3f3..00000000 Binary files a/screenshots/S-C-17-01.jpg and /dev/null differ diff --git a/screenshots/S-C-17-02.jpg b/screenshots/S-C-17-02.jpg deleted file mode 100644 index 0b9470b5..00000000 Binary files a/screenshots/S-C-17-02.jpg and /dev/null differ diff --git a/screenshots/S-C-17-03.jpg b/screenshots/S-C-17-03.jpg deleted file mode 100644 index a156d990..00000000 Binary files a/screenshots/S-C-17-03.jpg and /dev/null differ diff --git a/screenshots/S-C-17-04.jpg b/screenshots/S-C-17-04.jpg deleted file mode 100644 index b13d1bee..00000000 Binary files a/screenshots/S-C-17-04.jpg and /dev/null differ diff --git a/screenshots/S-C-17.jpg b/screenshots/S-C-17.jpg deleted file mode 100644 index 19be332d..00000000 Binary files a/screenshots/S-C-17.jpg and /dev/null differ diff --git a/screenshots/S-C-18-00.jpg b/screenshots/S-C-18-00.jpg deleted file mode 100644 index 931d6a9e..00000000 Binary files a/screenshots/S-C-18-00.jpg and /dev/null differ diff --git a/screenshots/S-C-18-01.jpg b/screenshots/S-C-18-01.jpg deleted file mode 100644 index a61b5818..00000000 Binary files a/screenshots/S-C-18-01.jpg and /dev/null differ diff --git a/screenshots/S-C-18-02.jpg b/screenshots/S-C-18-02.jpg deleted file mode 100644 index 853b33c6..00000000 Binary files a/screenshots/S-C-18-02.jpg and /dev/null differ diff --git a/screenshots/S-C-18-03.jpg b/screenshots/S-C-18-03.jpg deleted file mode 100644 index 73027b61..00000000 Binary files a/screenshots/S-C-18-03.jpg and /dev/null differ diff --git a/screenshots/S-C-18-04.jpg b/screenshots/S-C-18-04.jpg deleted file mode 100644 index db2bbe8b..00000000 Binary files a/screenshots/S-C-18-04.jpg and /dev/null differ diff --git a/screenshots/S-C-18.jpg b/screenshots/S-C-18.jpg deleted file mode 100644 index 00b9a53c..00000000 Binary files a/screenshots/S-C-18.jpg and /dev/null differ diff --git a/screenshots/S-C-19-00.jpg b/screenshots/S-C-19-00.jpg deleted file mode 100644 index f23f1400..00000000 Binary files a/screenshots/S-C-19-00.jpg and /dev/null differ diff --git a/screenshots/S-C-19-01.jpg b/screenshots/S-C-19-01.jpg deleted file mode 100644 index be979bee..00000000 Binary files a/screenshots/S-C-19-01.jpg and /dev/null differ diff --git a/screenshots/S-C-19-02.jpg b/screenshots/S-C-19-02.jpg deleted file mode 100644 index df106c1a..00000000 Binary files a/screenshots/S-C-19-02.jpg and /dev/null differ diff --git a/screenshots/S-C-19-03.jpg b/screenshots/S-C-19-03.jpg deleted file mode 100644 index feb598e4..00000000 Binary files a/screenshots/S-C-19-03.jpg and /dev/null differ diff --git a/screenshots/S-C-19-04.jpg b/screenshots/S-C-19-04.jpg deleted file mode 100644 index 3748922a..00000000 Binary files a/screenshots/S-C-19-04.jpg and /dev/null differ diff --git a/screenshots/S-C-19.jpg b/screenshots/S-C-19.jpg deleted file mode 100644 index f165478d..00000000 Binary files a/screenshots/S-C-19.jpg and /dev/null differ diff --git a/screenshots/S-D-00-00.jpg b/screenshots/S-D-00-00.jpg deleted file mode 100644 index 38395f61..00000000 Binary files a/screenshots/S-D-00-00.jpg and /dev/null differ diff --git a/screenshots/S-D-00-01.jpg b/screenshots/S-D-00-01.jpg deleted file mode 100644 index 74a83099..00000000 Binary files a/screenshots/S-D-00-01.jpg and /dev/null differ diff --git a/screenshots/S-D-00-02.jpg b/screenshots/S-D-00-02.jpg deleted file mode 100644 index 91f23a23..00000000 Binary files a/screenshots/S-D-00-02.jpg and /dev/null differ diff --git a/screenshots/S-D-00-03.jpg b/screenshots/S-D-00-03.jpg deleted file mode 100644 index 9509473d..00000000 Binary files a/screenshots/S-D-00-03.jpg and /dev/null differ diff --git a/screenshots/S-D-00-04.jpg b/screenshots/S-D-00-04.jpg deleted file mode 100644 index 1bcfba50..00000000 Binary files a/screenshots/S-D-00-04.jpg and /dev/null differ diff --git a/screenshots/S-D-00.jpg b/screenshots/S-D-00.jpg deleted file mode 100644 index 7bf917c6..00000000 Binary files a/screenshots/S-D-00.jpg and /dev/null differ diff --git a/screenshots/S-D-01-00.jpg b/screenshots/S-D-01-00.jpg deleted file mode 100644 index a7a8b92c..00000000 Binary files a/screenshots/S-D-01-00.jpg and /dev/null differ diff --git a/screenshots/S-D-01-01.jpg b/screenshots/S-D-01-01.jpg deleted file mode 100644 index 87920f87..00000000 Binary files a/screenshots/S-D-01-01.jpg and /dev/null differ diff --git a/screenshots/S-D-01-02.jpg b/screenshots/S-D-01-02.jpg deleted file mode 100644 index 24fd448d..00000000 Binary files a/screenshots/S-D-01-02.jpg and /dev/null differ diff --git a/screenshots/S-D-01-03.jpg b/screenshots/S-D-01-03.jpg deleted file mode 100644 index af69090e..00000000 Binary files a/screenshots/S-D-01-03.jpg and /dev/null differ diff --git a/screenshots/S-D-01-04.jpg b/screenshots/S-D-01-04.jpg deleted file mode 100644 index 89868f0d..00000000 Binary files a/screenshots/S-D-01-04.jpg and /dev/null differ diff --git a/screenshots/S-D-01.jpg b/screenshots/S-D-01.jpg deleted file mode 100644 index c1170b29..00000000 Binary files a/screenshots/S-D-01.jpg and /dev/null differ diff --git a/screenshots/S-D-02-00.jpg b/screenshots/S-D-02-00.jpg deleted file mode 100644 index 61b41c4b..00000000 Binary files a/screenshots/S-D-02-00.jpg and /dev/null differ diff --git a/screenshots/S-D-02-01.jpg b/screenshots/S-D-02-01.jpg deleted file mode 100644 index 5cceed40..00000000 Binary files a/screenshots/S-D-02-01.jpg and /dev/null differ diff --git a/screenshots/S-D-02-02.jpg b/screenshots/S-D-02-02.jpg deleted file mode 100644 index 5bb95e6e..00000000 Binary files a/screenshots/S-D-02-02.jpg and /dev/null differ diff --git a/screenshots/S-D-02-03.jpg b/screenshots/S-D-02-03.jpg deleted file mode 100644 index b84c33b1..00000000 Binary files a/screenshots/S-D-02-03.jpg and /dev/null differ diff --git a/screenshots/S-D-02-04.jpg b/screenshots/S-D-02-04.jpg deleted file mode 100644 index 7da1c8aa..00000000 Binary files a/screenshots/S-D-02-04.jpg and /dev/null differ diff --git a/screenshots/S-D-02.jpg b/screenshots/S-D-02.jpg deleted file mode 100644 index fbc9b058..00000000 Binary files a/screenshots/S-D-02.jpg and /dev/null differ diff --git a/screenshots/S-D-03-00.jpg b/screenshots/S-D-03-00.jpg deleted file mode 100644 index 75bb86f9..00000000 Binary files a/screenshots/S-D-03-00.jpg and /dev/null differ diff --git a/screenshots/S-D-03-01.jpg b/screenshots/S-D-03-01.jpg deleted file mode 100644 index d59ca5d2..00000000 Binary files a/screenshots/S-D-03-01.jpg and /dev/null differ diff --git a/screenshots/S-D-03-02.jpg b/screenshots/S-D-03-02.jpg deleted file mode 100644 index 62079f13..00000000 Binary files a/screenshots/S-D-03-02.jpg and /dev/null differ diff --git a/screenshots/S-D-03-03.jpg b/screenshots/S-D-03-03.jpg deleted file mode 100644 index bc6592ef..00000000 Binary files a/screenshots/S-D-03-03.jpg and /dev/null differ diff --git a/screenshots/S-D-03-04.jpg b/screenshots/S-D-03-04.jpg deleted file mode 100644 index 40b5c46b..00000000 Binary files a/screenshots/S-D-03-04.jpg and /dev/null differ diff --git a/screenshots/S-D-03.jpg b/screenshots/S-D-03.jpg deleted file mode 100644 index 8300107b..00000000 Binary files a/screenshots/S-D-03.jpg and /dev/null differ diff --git a/screenshots/S-D-04-00.jpg b/screenshots/S-D-04-00.jpg deleted file mode 100644 index fb616656..00000000 Binary files a/screenshots/S-D-04-00.jpg and /dev/null differ diff --git a/screenshots/S-D-04-01.jpg b/screenshots/S-D-04-01.jpg deleted file mode 100644 index bc892fec..00000000 Binary files a/screenshots/S-D-04-01.jpg and /dev/null differ diff --git a/screenshots/S-D-04-02.jpg b/screenshots/S-D-04-02.jpg deleted file mode 100644 index 7771beb5..00000000 Binary files a/screenshots/S-D-04-02.jpg and /dev/null differ diff --git a/screenshots/S-D-04-03.jpg b/screenshots/S-D-04-03.jpg deleted file mode 100644 index 51221f17..00000000 Binary files a/screenshots/S-D-04-03.jpg and /dev/null differ diff --git a/screenshots/S-D-04-04.jpg b/screenshots/S-D-04-04.jpg deleted file mode 100644 index 62dcdb64..00000000 Binary files a/screenshots/S-D-04-04.jpg and /dev/null differ diff --git a/screenshots/S-D-04.jpg b/screenshots/S-D-04.jpg deleted file mode 100644 index 71f685cc..00000000 Binary files a/screenshots/S-D-04.jpg and /dev/null differ diff --git a/screenshots/S-D-05-00.jpg b/screenshots/S-D-05-00.jpg deleted file mode 100644 index f3374315..00000000 Binary files a/screenshots/S-D-05-00.jpg and /dev/null differ diff --git a/screenshots/S-D-05-01.jpg b/screenshots/S-D-05-01.jpg deleted file mode 100644 index 9f9dc3ad..00000000 Binary files a/screenshots/S-D-05-01.jpg and /dev/null differ diff --git a/screenshots/S-D-05-02.jpg b/screenshots/S-D-05-02.jpg deleted file mode 100644 index b0043e5d..00000000 Binary files a/screenshots/S-D-05-02.jpg and /dev/null differ diff --git a/screenshots/S-D-05-03.jpg b/screenshots/S-D-05-03.jpg deleted file mode 100644 index 9713d39c..00000000 Binary files a/screenshots/S-D-05-03.jpg and /dev/null differ diff --git a/screenshots/S-D-05-04.jpg b/screenshots/S-D-05-04.jpg deleted file mode 100644 index 60e6cf5a..00000000 Binary files a/screenshots/S-D-05-04.jpg and /dev/null differ diff --git a/screenshots/S-D-05.jpg b/screenshots/S-D-05.jpg deleted file mode 100644 index e4c7fc42..00000000 Binary files a/screenshots/S-D-05.jpg and /dev/null differ diff --git a/screenshots/S-D-06-00.jpg b/screenshots/S-D-06-00.jpg deleted file mode 100644 index 7d8ed391..00000000 Binary files a/screenshots/S-D-06-00.jpg and /dev/null differ diff --git a/screenshots/S-D-06-01.jpg b/screenshots/S-D-06-01.jpg deleted file mode 100644 index 175796c5..00000000 Binary files a/screenshots/S-D-06-01.jpg and /dev/null differ diff --git a/screenshots/S-D-06-02.jpg b/screenshots/S-D-06-02.jpg deleted file mode 100644 index d2589ddb..00000000 Binary files a/screenshots/S-D-06-02.jpg and /dev/null differ diff --git a/screenshots/S-D-06-03.jpg b/screenshots/S-D-06-03.jpg deleted file mode 100644 index d4f56a17..00000000 Binary files a/screenshots/S-D-06-03.jpg and /dev/null differ diff --git a/screenshots/S-D-06-04.jpg b/screenshots/S-D-06-04.jpg deleted file mode 100644 index 268fe6f5..00000000 Binary files a/screenshots/S-D-06-04.jpg and /dev/null differ diff --git a/screenshots/S-D-06.jpg b/screenshots/S-D-06.jpg deleted file mode 100644 index dadbb5b7..00000000 Binary files a/screenshots/S-D-06.jpg and /dev/null differ diff --git a/screenshots/S-D-07-00.jpg b/screenshots/S-D-07-00.jpg deleted file mode 100644 index 37ba0af7..00000000 Binary files a/screenshots/S-D-07-00.jpg and /dev/null differ diff --git a/screenshots/S-D-07-01.jpg b/screenshots/S-D-07-01.jpg deleted file mode 100644 index 7c0dd153..00000000 Binary files a/screenshots/S-D-07-01.jpg and /dev/null differ diff --git a/screenshots/S-D-07-02.jpg b/screenshots/S-D-07-02.jpg deleted file mode 100644 index 2c985583..00000000 Binary files a/screenshots/S-D-07-02.jpg and /dev/null differ diff --git a/screenshots/S-D-07-03.jpg b/screenshots/S-D-07-03.jpg deleted file mode 100644 index bc4f05e5..00000000 Binary files a/screenshots/S-D-07-03.jpg and /dev/null differ diff --git a/screenshots/S-D-07-04.jpg b/screenshots/S-D-07-04.jpg deleted file mode 100644 index 3678e4a4..00000000 Binary files a/screenshots/S-D-07-04.jpg and /dev/null differ diff --git a/screenshots/S-D-07.jpg b/screenshots/S-D-07.jpg deleted file mode 100644 index cbbba5d0..00000000 Binary files a/screenshots/S-D-07.jpg and /dev/null differ diff --git a/screenshots/S-D-08-00.jpg b/screenshots/S-D-08-00.jpg deleted file mode 100644 index fd833d9b..00000000 Binary files a/screenshots/S-D-08-00.jpg and /dev/null differ diff --git a/screenshots/S-D-08-01.jpg b/screenshots/S-D-08-01.jpg deleted file mode 100644 index 7621dbae..00000000 Binary files a/screenshots/S-D-08-01.jpg and /dev/null differ diff --git a/screenshots/S-D-08-02.jpg b/screenshots/S-D-08-02.jpg deleted file mode 100644 index b0ea0c0c..00000000 Binary files a/screenshots/S-D-08-02.jpg and /dev/null differ diff --git a/screenshots/S-D-08-03.jpg b/screenshots/S-D-08-03.jpg deleted file mode 100644 index 8c5d1a8e..00000000 Binary files a/screenshots/S-D-08-03.jpg and /dev/null differ diff --git a/screenshots/S-D-08-04.jpg b/screenshots/S-D-08-04.jpg deleted file mode 100644 index f9a6a3ca..00000000 Binary files a/screenshots/S-D-08-04.jpg and /dev/null differ diff --git a/screenshots/S-D-08.jpg b/screenshots/S-D-08.jpg deleted file mode 100644 index a2292418..00000000 Binary files a/screenshots/S-D-08.jpg and /dev/null differ diff --git a/screenshots/S-D-09-00.jpg b/screenshots/S-D-09-00.jpg deleted file mode 100644 index f717012d..00000000 Binary files a/screenshots/S-D-09-00.jpg and /dev/null differ diff --git a/screenshots/S-D-09-01.jpg b/screenshots/S-D-09-01.jpg deleted file mode 100644 index 2b3cee6b..00000000 Binary files a/screenshots/S-D-09-01.jpg and /dev/null differ diff --git a/screenshots/S-D-09-02.jpg b/screenshots/S-D-09-02.jpg deleted file mode 100644 index 774297fe..00000000 Binary files a/screenshots/S-D-09-02.jpg and /dev/null differ diff --git a/screenshots/S-D-09-03.jpg b/screenshots/S-D-09-03.jpg deleted file mode 100644 index ae8b1853..00000000 Binary files a/screenshots/S-D-09-03.jpg and /dev/null differ diff --git a/screenshots/S-D-09-04.jpg b/screenshots/S-D-09-04.jpg deleted file mode 100644 index faf9cc8e..00000000 Binary files a/screenshots/S-D-09-04.jpg and /dev/null differ diff --git a/screenshots/S-D-09.jpg b/screenshots/S-D-09.jpg deleted file mode 100644 index 10a1766e..00000000 Binary files a/screenshots/S-D-09.jpg and /dev/null differ diff --git a/screenshots/S-D-10-00.jpg b/screenshots/S-D-10-00.jpg deleted file mode 100644 index 69273fb0..00000000 Binary files a/screenshots/S-D-10-00.jpg and /dev/null differ diff --git a/screenshots/S-D-10-01.jpg b/screenshots/S-D-10-01.jpg deleted file mode 100644 index c61b2ae3..00000000 Binary files a/screenshots/S-D-10-01.jpg and /dev/null differ diff --git a/screenshots/S-D-10-02.jpg b/screenshots/S-D-10-02.jpg deleted file mode 100644 index f1f367f6..00000000 Binary files a/screenshots/S-D-10-02.jpg and /dev/null differ diff --git a/screenshots/S-D-10-03.jpg b/screenshots/S-D-10-03.jpg deleted file mode 100644 index c1962284..00000000 Binary files a/screenshots/S-D-10-03.jpg and /dev/null differ diff --git a/screenshots/S-D-10-04.jpg b/screenshots/S-D-10-04.jpg deleted file mode 100644 index 75c7abd9..00000000 Binary files a/screenshots/S-D-10-04.jpg and /dev/null differ diff --git a/screenshots/S-D-10.jpg b/screenshots/S-D-10.jpg deleted file mode 100644 index 46d860b7..00000000 Binary files a/screenshots/S-D-10.jpg and /dev/null differ diff --git a/screenshots/S-D-11-00.jpg b/screenshots/S-D-11-00.jpg deleted file mode 100644 index dd91fcc0..00000000 Binary files a/screenshots/S-D-11-00.jpg and /dev/null differ diff --git a/screenshots/S-D-11-01.jpg b/screenshots/S-D-11-01.jpg deleted file mode 100644 index 4e59adfb..00000000 Binary files a/screenshots/S-D-11-01.jpg and /dev/null differ diff --git a/screenshots/S-D-11-02.jpg b/screenshots/S-D-11-02.jpg deleted file mode 100644 index 1e9317fd..00000000 Binary files a/screenshots/S-D-11-02.jpg and /dev/null differ diff --git a/screenshots/S-D-11-03.jpg b/screenshots/S-D-11-03.jpg deleted file mode 100644 index 2028507c..00000000 Binary files a/screenshots/S-D-11-03.jpg and /dev/null differ diff --git a/screenshots/S-D-11-04.jpg b/screenshots/S-D-11-04.jpg deleted file mode 100644 index 45ff8def..00000000 Binary files a/screenshots/S-D-11-04.jpg and /dev/null differ diff --git a/screenshots/S-D-11.jpg b/screenshots/S-D-11.jpg deleted file mode 100644 index 28dec82d..00000000 Binary files a/screenshots/S-D-11.jpg and /dev/null differ diff --git a/screenshots/S-D-12-00.jpg b/screenshots/S-D-12-00.jpg deleted file mode 100644 index 9b219d98..00000000 Binary files a/screenshots/S-D-12-00.jpg and /dev/null differ diff --git a/screenshots/S-D-12-01.jpg b/screenshots/S-D-12-01.jpg deleted file mode 100644 index 38eb0fa8..00000000 Binary files a/screenshots/S-D-12-01.jpg and /dev/null differ diff --git a/screenshots/S-D-12-02.jpg b/screenshots/S-D-12-02.jpg deleted file mode 100644 index 547991e7..00000000 Binary files a/screenshots/S-D-12-02.jpg and /dev/null differ diff --git a/screenshots/S-D-12-03.jpg b/screenshots/S-D-12-03.jpg deleted file mode 100644 index b809219e..00000000 Binary files a/screenshots/S-D-12-03.jpg and /dev/null differ diff --git a/screenshots/S-D-12-04.jpg b/screenshots/S-D-12-04.jpg deleted file mode 100644 index 863403b2..00000000 Binary files a/screenshots/S-D-12-04.jpg and /dev/null differ diff --git a/screenshots/S-D-12.jpg b/screenshots/S-D-12.jpg deleted file mode 100644 index ecf9f901..00000000 Binary files a/screenshots/S-D-12.jpg and /dev/null differ diff --git a/screenshots/S-D-13-00.jpg b/screenshots/S-D-13-00.jpg deleted file mode 100644 index 77254f6a..00000000 Binary files a/screenshots/S-D-13-00.jpg and /dev/null differ diff --git a/screenshots/S-D-13-01.jpg b/screenshots/S-D-13-01.jpg deleted file mode 100644 index cabe6073..00000000 Binary files a/screenshots/S-D-13-01.jpg and /dev/null differ diff --git a/screenshots/S-D-13-02.jpg b/screenshots/S-D-13-02.jpg deleted file mode 100644 index 8a994bc6..00000000 Binary files a/screenshots/S-D-13-02.jpg and /dev/null differ diff --git a/screenshots/S-D-13-03.jpg b/screenshots/S-D-13-03.jpg deleted file mode 100644 index 15533d64..00000000 Binary files a/screenshots/S-D-13-03.jpg and /dev/null differ diff --git a/screenshots/S-D-13-04.jpg b/screenshots/S-D-13-04.jpg deleted file mode 100644 index 17a0af60..00000000 Binary files a/screenshots/S-D-13-04.jpg and /dev/null differ diff --git a/screenshots/S-D-13.jpg b/screenshots/S-D-13.jpg deleted file mode 100644 index 9d6adb6e..00000000 Binary files a/screenshots/S-D-13.jpg and /dev/null differ diff --git a/screenshots/S-D-14-00.jpg b/screenshots/S-D-14-00.jpg deleted file mode 100644 index 54261540..00000000 Binary files a/screenshots/S-D-14-00.jpg and /dev/null differ diff --git a/screenshots/S-D-14-01.jpg b/screenshots/S-D-14-01.jpg deleted file mode 100644 index 21d49a48..00000000 Binary files a/screenshots/S-D-14-01.jpg and /dev/null differ diff --git a/screenshots/S-D-14-02.jpg b/screenshots/S-D-14-02.jpg deleted file mode 100644 index 1081b177..00000000 Binary files a/screenshots/S-D-14-02.jpg and /dev/null differ diff --git a/screenshots/S-D-14-03.jpg b/screenshots/S-D-14-03.jpg deleted file mode 100644 index 5a1fffc5..00000000 Binary files a/screenshots/S-D-14-03.jpg and /dev/null differ diff --git a/screenshots/S-D-14-04.jpg b/screenshots/S-D-14-04.jpg deleted file mode 100644 index 7189a392..00000000 Binary files a/screenshots/S-D-14-04.jpg and /dev/null differ diff --git a/screenshots/S-D-14.jpg b/screenshots/S-D-14.jpg deleted file mode 100644 index 58c616b0..00000000 Binary files a/screenshots/S-D-14.jpg and /dev/null differ diff --git a/screenshots/S-D-15-00.jpg b/screenshots/S-D-15-00.jpg deleted file mode 100644 index 63d68128..00000000 Binary files a/screenshots/S-D-15-00.jpg and /dev/null differ diff --git a/screenshots/S-D-15-01.jpg b/screenshots/S-D-15-01.jpg deleted file mode 100644 index d9f59205..00000000 Binary files a/screenshots/S-D-15-01.jpg and /dev/null differ diff --git a/screenshots/S-D-15-02.jpg b/screenshots/S-D-15-02.jpg deleted file mode 100644 index 5c64848c..00000000 Binary files a/screenshots/S-D-15-02.jpg and /dev/null differ diff --git a/screenshots/S-D-15-03.jpg b/screenshots/S-D-15-03.jpg deleted file mode 100644 index 31e68955..00000000 Binary files a/screenshots/S-D-15-03.jpg and /dev/null differ diff --git a/screenshots/S-D-15-04.jpg b/screenshots/S-D-15-04.jpg deleted file mode 100644 index a62dc484..00000000 Binary files a/screenshots/S-D-15-04.jpg and /dev/null differ diff --git a/screenshots/S-D-15.jpg b/screenshots/S-D-15.jpg deleted file mode 100644 index 79629c35..00000000 Binary files a/screenshots/S-D-15.jpg and /dev/null differ diff --git a/screenshots/S-D-16-00.jpg b/screenshots/S-D-16-00.jpg deleted file mode 100644 index 32549419..00000000 Binary files a/screenshots/S-D-16-00.jpg and /dev/null differ diff --git a/screenshots/S-D-16-01.jpg b/screenshots/S-D-16-01.jpg deleted file mode 100644 index 0523a5d0..00000000 Binary files a/screenshots/S-D-16-01.jpg and /dev/null differ diff --git a/screenshots/S-D-16-02.jpg b/screenshots/S-D-16-02.jpg deleted file mode 100644 index 78b20887..00000000 Binary files a/screenshots/S-D-16-02.jpg and /dev/null differ diff --git a/screenshots/S-D-16-03.jpg b/screenshots/S-D-16-03.jpg deleted file mode 100644 index 5aa9a387..00000000 Binary files a/screenshots/S-D-16-03.jpg and /dev/null differ diff --git a/screenshots/S-D-16-04.jpg b/screenshots/S-D-16-04.jpg deleted file mode 100644 index 82d8ba9f..00000000 Binary files a/screenshots/S-D-16-04.jpg and /dev/null differ diff --git a/screenshots/S-D-16.jpg b/screenshots/S-D-16.jpg deleted file mode 100644 index d317de89..00000000 Binary files a/screenshots/S-D-16.jpg and /dev/null differ diff --git a/screenshots/S-D-17-00.jpg b/screenshots/S-D-17-00.jpg deleted file mode 100644 index 94bff352..00000000 Binary files a/screenshots/S-D-17-00.jpg and /dev/null differ diff --git a/screenshots/S-D-17-01.jpg b/screenshots/S-D-17-01.jpg deleted file mode 100644 index 304e9b3b..00000000 Binary files a/screenshots/S-D-17-01.jpg and /dev/null differ diff --git a/screenshots/S-D-17-02.jpg b/screenshots/S-D-17-02.jpg deleted file mode 100644 index d60857ae..00000000 Binary files a/screenshots/S-D-17-02.jpg and /dev/null differ diff --git a/screenshots/S-D-17-03.jpg b/screenshots/S-D-17-03.jpg deleted file mode 100644 index fe97de81..00000000 Binary files a/screenshots/S-D-17-03.jpg and /dev/null differ diff --git a/screenshots/S-D-17-04.jpg b/screenshots/S-D-17-04.jpg deleted file mode 100644 index 23634078..00000000 Binary files a/screenshots/S-D-17-04.jpg and /dev/null differ diff --git a/screenshots/S-D-17.jpg b/screenshots/S-D-17.jpg deleted file mode 100644 index e87bdb0b..00000000 Binary files a/screenshots/S-D-17.jpg and /dev/null differ diff --git a/screenshots/S-D-18-00.jpg b/screenshots/S-D-18-00.jpg deleted file mode 100644 index 0f64967c..00000000 Binary files a/screenshots/S-D-18-00.jpg and /dev/null differ diff --git a/screenshots/S-D-18-01.jpg b/screenshots/S-D-18-01.jpg deleted file mode 100644 index 3be20197..00000000 Binary files a/screenshots/S-D-18-01.jpg and /dev/null differ diff --git a/screenshots/S-D-18-02.jpg b/screenshots/S-D-18-02.jpg deleted file mode 100644 index 699eab10..00000000 Binary files a/screenshots/S-D-18-02.jpg and /dev/null differ diff --git a/screenshots/S-D-18-03.jpg b/screenshots/S-D-18-03.jpg deleted file mode 100644 index 1e09a89a..00000000 Binary files a/screenshots/S-D-18-03.jpg and /dev/null differ diff --git a/screenshots/S-D-18-04.jpg b/screenshots/S-D-18-04.jpg deleted file mode 100644 index cc7a3075..00000000 Binary files a/screenshots/S-D-18-04.jpg and /dev/null differ diff --git a/screenshots/S-D-18.jpg b/screenshots/S-D-18.jpg deleted file mode 100644 index f0fe31d6..00000000 Binary files a/screenshots/S-D-18.jpg and /dev/null differ diff --git a/screenshots/S-D-19-00.jpg b/screenshots/S-D-19-00.jpg deleted file mode 100644 index edbe58b3..00000000 Binary files a/screenshots/S-D-19-00.jpg and /dev/null differ diff --git a/screenshots/S-D-19-01.jpg b/screenshots/S-D-19-01.jpg deleted file mode 100644 index aca3a54a..00000000 Binary files a/screenshots/S-D-19-01.jpg and /dev/null differ diff --git a/screenshots/S-D-19-02.jpg b/screenshots/S-D-19-02.jpg deleted file mode 100644 index 011d5aad..00000000 Binary files a/screenshots/S-D-19-02.jpg and /dev/null differ diff --git a/screenshots/S-D-19-03.jpg b/screenshots/S-D-19-03.jpg deleted file mode 100644 index c75449f2..00000000 Binary files a/screenshots/S-D-19-03.jpg and /dev/null differ diff --git a/screenshots/S-D-19-04.jpg b/screenshots/S-D-19-04.jpg deleted file mode 100644 index 5a1e93cc..00000000 Binary files a/screenshots/S-D-19-04.jpg and /dev/null differ diff --git a/screenshots/S-D-19.jpg b/screenshots/S-D-19.jpg deleted file mode 100644 index eb803287..00000000 Binary files a/screenshots/S-D-19.jpg and /dev/null differ diff --git a/screenshots/S-E-00-00.jpg b/screenshots/S-E-00-00.jpg deleted file mode 100644 index 90cd9d12..00000000 Binary files a/screenshots/S-E-00-00.jpg and /dev/null differ diff --git a/screenshots/S-E-00-01.jpg b/screenshots/S-E-00-01.jpg deleted file mode 100644 index 513643a8..00000000 Binary files a/screenshots/S-E-00-01.jpg and /dev/null differ diff --git a/screenshots/S-E-00-02.jpg b/screenshots/S-E-00-02.jpg deleted file mode 100644 index b998b0d1..00000000 Binary files a/screenshots/S-E-00-02.jpg and /dev/null differ diff --git a/screenshots/S-E-00-03.jpg b/screenshots/S-E-00-03.jpg deleted file mode 100644 index 109d577a..00000000 Binary files a/screenshots/S-E-00-03.jpg and /dev/null differ diff --git a/screenshots/S-E-00-04.jpg b/screenshots/S-E-00-04.jpg deleted file mode 100644 index e50290d5..00000000 Binary files a/screenshots/S-E-00-04.jpg and /dev/null differ diff --git a/screenshots/S-E-00.jpg b/screenshots/S-E-00.jpg deleted file mode 100644 index 17971cae..00000000 Binary files a/screenshots/S-E-00.jpg and /dev/null differ diff --git a/screenshots/S-E-01-00.jpg b/screenshots/S-E-01-00.jpg deleted file mode 100644 index f79e3a1b..00000000 Binary files a/screenshots/S-E-01-00.jpg and /dev/null differ diff --git a/screenshots/S-E-01-01.jpg b/screenshots/S-E-01-01.jpg deleted file mode 100644 index 02632b77..00000000 Binary files a/screenshots/S-E-01-01.jpg and /dev/null differ diff --git a/screenshots/S-E-01-02.jpg b/screenshots/S-E-01-02.jpg deleted file mode 100644 index 1db91934..00000000 Binary files a/screenshots/S-E-01-02.jpg and /dev/null differ diff --git a/screenshots/S-E-01-03.jpg b/screenshots/S-E-01-03.jpg deleted file mode 100644 index 7747554b..00000000 Binary files a/screenshots/S-E-01-03.jpg and /dev/null differ diff --git a/screenshots/S-E-01-04.jpg b/screenshots/S-E-01-04.jpg deleted file mode 100644 index 1be420e5..00000000 Binary files a/screenshots/S-E-01-04.jpg and /dev/null differ diff --git a/screenshots/S-E-01.jpg b/screenshots/S-E-01.jpg deleted file mode 100644 index 57b1e7c7..00000000 Binary files a/screenshots/S-E-01.jpg and /dev/null differ diff --git a/screenshots/S-E-02-00.jpg b/screenshots/S-E-02-00.jpg deleted file mode 100644 index 74f86582..00000000 Binary files a/screenshots/S-E-02-00.jpg and /dev/null differ diff --git a/screenshots/S-E-02-01.jpg b/screenshots/S-E-02-01.jpg deleted file mode 100644 index 7aaeef93..00000000 Binary files a/screenshots/S-E-02-01.jpg and /dev/null differ diff --git a/screenshots/S-E-02-02.jpg b/screenshots/S-E-02-02.jpg deleted file mode 100644 index 8c25e120..00000000 Binary files a/screenshots/S-E-02-02.jpg and /dev/null differ diff --git a/screenshots/S-E-02-03.jpg b/screenshots/S-E-02-03.jpg deleted file mode 100644 index 944fa6ee..00000000 Binary files a/screenshots/S-E-02-03.jpg and /dev/null differ diff --git a/screenshots/S-E-02-04.jpg b/screenshots/S-E-02-04.jpg deleted file mode 100644 index b9b3d538..00000000 Binary files a/screenshots/S-E-02-04.jpg and /dev/null differ diff --git a/screenshots/S-E-02.jpg b/screenshots/S-E-02.jpg deleted file mode 100644 index 90b84f84..00000000 Binary files a/screenshots/S-E-02.jpg and /dev/null differ diff --git a/screenshots/S-E-03-00.jpg b/screenshots/S-E-03-00.jpg deleted file mode 100644 index 035ee028..00000000 Binary files a/screenshots/S-E-03-00.jpg and /dev/null differ diff --git a/screenshots/S-E-03-01.jpg b/screenshots/S-E-03-01.jpg deleted file mode 100644 index 386e4501..00000000 Binary files a/screenshots/S-E-03-01.jpg and /dev/null differ diff --git a/screenshots/S-E-03-02.jpg b/screenshots/S-E-03-02.jpg deleted file mode 100644 index 9ebcc47d..00000000 Binary files a/screenshots/S-E-03-02.jpg and /dev/null differ diff --git a/screenshots/S-E-03-03.jpg b/screenshots/S-E-03-03.jpg deleted file mode 100644 index 36b1b1c5..00000000 Binary files a/screenshots/S-E-03-03.jpg and /dev/null differ diff --git a/screenshots/S-E-03-04.jpg b/screenshots/S-E-03-04.jpg deleted file mode 100644 index d6c03f1d..00000000 Binary files a/screenshots/S-E-03-04.jpg and /dev/null differ diff --git a/screenshots/S-E-03.jpg b/screenshots/S-E-03.jpg deleted file mode 100644 index b34ccf8b..00000000 Binary files a/screenshots/S-E-03.jpg and /dev/null differ diff --git a/screenshots/S-E-04-00.jpg b/screenshots/S-E-04-00.jpg deleted file mode 100644 index e145fa88..00000000 Binary files a/screenshots/S-E-04-00.jpg and /dev/null differ diff --git a/screenshots/S-E-04-01.jpg b/screenshots/S-E-04-01.jpg deleted file mode 100644 index e1a375ce..00000000 Binary files a/screenshots/S-E-04-01.jpg and /dev/null differ diff --git a/screenshots/S-E-04-02.jpg b/screenshots/S-E-04-02.jpg deleted file mode 100644 index b4beb154..00000000 Binary files a/screenshots/S-E-04-02.jpg and /dev/null differ diff --git a/screenshots/S-E-04-03.jpg b/screenshots/S-E-04-03.jpg deleted file mode 100644 index c0bdbfbb..00000000 Binary files a/screenshots/S-E-04-03.jpg and /dev/null differ diff --git a/screenshots/S-E-04-04.jpg b/screenshots/S-E-04-04.jpg deleted file mode 100644 index 6115c873..00000000 Binary files a/screenshots/S-E-04-04.jpg and /dev/null differ diff --git a/screenshots/S-E-04.jpg b/screenshots/S-E-04.jpg deleted file mode 100644 index 7f898c4d..00000000 Binary files a/screenshots/S-E-04.jpg and /dev/null differ diff --git a/screenshots/S-E-05-00.jpg b/screenshots/S-E-05-00.jpg deleted file mode 100644 index 44da3da2..00000000 Binary files a/screenshots/S-E-05-00.jpg and /dev/null differ diff --git a/screenshots/S-E-05-01.jpg b/screenshots/S-E-05-01.jpg deleted file mode 100644 index 8a9d5f93..00000000 Binary files a/screenshots/S-E-05-01.jpg and /dev/null differ diff --git a/screenshots/S-E-05-02.jpg b/screenshots/S-E-05-02.jpg deleted file mode 100644 index 9077a387..00000000 Binary files a/screenshots/S-E-05-02.jpg and /dev/null differ diff --git a/screenshots/S-E-05-03.jpg b/screenshots/S-E-05-03.jpg deleted file mode 100644 index bb48f368..00000000 Binary files a/screenshots/S-E-05-03.jpg and /dev/null differ diff --git a/screenshots/S-E-05-04.jpg b/screenshots/S-E-05-04.jpg deleted file mode 100644 index e0abc9e5..00000000 Binary files a/screenshots/S-E-05-04.jpg and /dev/null differ diff --git a/screenshots/S-E-05.jpg b/screenshots/S-E-05.jpg deleted file mode 100644 index 19344f2d..00000000 Binary files a/screenshots/S-E-05.jpg and /dev/null differ diff --git a/screenshots/S-E-06-00.jpg b/screenshots/S-E-06-00.jpg deleted file mode 100644 index a3be4c01..00000000 Binary files a/screenshots/S-E-06-00.jpg and /dev/null differ diff --git a/screenshots/S-E-06-01.jpg b/screenshots/S-E-06-01.jpg deleted file mode 100644 index a20a2252..00000000 Binary files a/screenshots/S-E-06-01.jpg and /dev/null differ diff --git a/screenshots/S-E-06-02.jpg b/screenshots/S-E-06-02.jpg deleted file mode 100644 index 750166e7..00000000 Binary files a/screenshots/S-E-06-02.jpg and /dev/null differ diff --git a/screenshots/S-E-06-03.jpg b/screenshots/S-E-06-03.jpg deleted file mode 100644 index dcb5a14e..00000000 Binary files a/screenshots/S-E-06-03.jpg and /dev/null differ diff --git a/screenshots/S-E-06-04.jpg b/screenshots/S-E-06-04.jpg deleted file mode 100644 index c2fa2ecb..00000000 Binary files a/screenshots/S-E-06-04.jpg and /dev/null differ diff --git a/screenshots/S-E-06.jpg b/screenshots/S-E-06.jpg deleted file mode 100644 index 90dee566..00000000 Binary files a/screenshots/S-E-06.jpg and /dev/null differ diff --git a/screenshots/S-E-07-00.jpg b/screenshots/S-E-07-00.jpg deleted file mode 100644 index 15e0b4c6..00000000 Binary files a/screenshots/S-E-07-00.jpg and /dev/null differ diff --git a/screenshots/S-E-07-01.jpg b/screenshots/S-E-07-01.jpg deleted file mode 100644 index ff207ff1..00000000 Binary files a/screenshots/S-E-07-01.jpg and /dev/null differ diff --git a/screenshots/S-E-07-02.jpg b/screenshots/S-E-07-02.jpg deleted file mode 100644 index 7d74cb50..00000000 Binary files a/screenshots/S-E-07-02.jpg and /dev/null differ diff --git a/screenshots/S-E-07-03.jpg b/screenshots/S-E-07-03.jpg deleted file mode 100644 index aea64d1a..00000000 Binary files a/screenshots/S-E-07-03.jpg and /dev/null differ diff --git a/screenshots/S-E-07-04.jpg b/screenshots/S-E-07-04.jpg deleted file mode 100644 index 9c204fc5..00000000 Binary files a/screenshots/S-E-07-04.jpg and /dev/null differ diff --git a/screenshots/S-E-07.jpg b/screenshots/S-E-07.jpg deleted file mode 100644 index 89aa9f61..00000000 Binary files a/screenshots/S-E-07.jpg and /dev/null differ diff --git a/screenshots/S-E-08-00.jpg b/screenshots/S-E-08-00.jpg deleted file mode 100644 index a61caf6a..00000000 Binary files a/screenshots/S-E-08-00.jpg and /dev/null differ diff --git a/screenshots/S-E-08-01.jpg b/screenshots/S-E-08-01.jpg deleted file mode 100644 index 1e344cc5..00000000 Binary files a/screenshots/S-E-08-01.jpg and /dev/null differ diff --git a/screenshots/S-E-08-02.jpg b/screenshots/S-E-08-02.jpg deleted file mode 100644 index 88e805f8..00000000 Binary files a/screenshots/S-E-08-02.jpg and /dev/null differ diff --git a/screenshots/S-E-08-03.jpg b/screenshots/S-E-08-03.jpg deleted file mode 100644 index 2384f75c..00000000 Binary files a/screenshots/S-E-08-03.jpg and /dev/null differ diff --git a/screenshots/S-E-08-04.jpg b/screenshots/S-E-08-04.jpg deleted file mode 100644 index 1870f852..00000000 Binary files a/screenshots/S-E-08-04.jpg and /dev/null differ diff --git a/screenshots/S-E-08.jpg b/screenshots/S-E-08.jpg deleted file mode 100644 index e05a5ada..00000000 Binary files a/screenshots/S-E-08.jpg and /dev/null differ diff --git a/screenshots/S-E-09-00.jpg b/screenshots/S-E-09-00.jpg deleted file mode 100644 index 6ccfe4f8..00000000 Binary files a/screenshots/S-E-09-00.jpg and /dev/null differ diff --git a/screenshots/S-E-09-01.jpg b/screenshots/S-E-09-01.jpg deleted file mode 100644 index 5bb6601a..00000000 Binary files a/screenshots/S-E-09-01.jpg and /dev/null differ diff --git a/screenshots/S-E-09-02.jpg b/screenshots/S-E-09-02.jpg deleted file mode 100644 index 1b9adfed..00000000 Binary files a/screenshots/S-E-09-02.jpg and /dev/null differ diff --git a/screenshots/S-E-09-03.jpg b/screenshots/S-E-09-03.jpg deleted file mode 100644 index 33aee924..00000000 Binary files a/screenshots/S-E-09-03.jpg and /dev/null differ diff --git a/screenshots/S-E-09-04.jpg b/screenshots/S-E-09-04.jpg deleted file mode 100644 index 3a3f7af6..00000000 Binary files a/screenshots/S-E-09-04.jpg and /dev/null differ diff --git a/screenshots/S-E-09.jpg b/screenshots/S-E-09.jpg deleted file mode 100644 index 33065223..00000000 Binary files a/screenshots/S-E-09.jpg and /dev/null differ diff --git a/screenshots/S-E-10-00.jpg b/screenshots/S-E-10-00.jpg deleted file mode 100644 index dc2acd72..00000000 Binary files a/screenshots/S-E-10-00.jpg and /dev/null differ diff --git a/screenshots/S-E-10-01.jpg b/screenshots/S-E-10-01.jpg deleted file mode 100644 index 3c9f3b57..00000000 Binary files a/screenshots/S-E-10-01.jpg and /dev/null differ diff --git a/screenshots/S-E-10-02.jpg b/screenshots/S-E-10-02.jpg deleted file mode 100644 index ba548f8d..00000000 Binary files a/screenshots/S-E-10-02.jpg and /dev/null differ diff --git a/screenshots/S-E-10-03.jpg b/screenshots/S-E-10-03.jpg deleted file mode 100644 index f0b9d917..00000000 Binary files a/screenshots/S-E-10-03.jpg and /dev/null differ diff --git a/screenshots/S-E-10-04.jpg b/screenshots/S-E-10-04.jpg deleted file mode 100644 index 5472f591..00000000 Binary files a/screenshots/S-E-10-04.jpg and /dev/null differ diff --git a/screenshots/S-E-10.jpg b/screenshots/S-E-10.jpg deleted file mode 100644 index 9a98e6e0..00000000 Binary files a/screenshots/S-E-10.jpg and /dev/null differ diff --git a/screenshots/S-E-11-00.jpg b/screenshots/S-E-11-00.jpg deleted file mode 100644 index 7b9ee5a9..00000000 Binary files a/screenshots/S-E-11-00.jpg and /dev/null differ diff --git a/screenshots/S-E-11-01.jpg b/screenshots/S-E-11-01.jpg deleted file mode 100644 index b60970fe..00000000 Binary files a/screenshots/S-E-11-01.jpg and /dev/null differ diff --git a/screenshots/S-E-11-02.jpg b/screenshots/S-E-11-02.jpg deleted file mode 100644 index 1aa7e6bd..00000000 Binary files a/screenshots/S-E-11-02.jpg and /dev/null differ diff --git a/screenshots/S-E-11-03.jpg b/screenshots/S-E-11-03.jpg deleted file mode 100644 index c860977d..00000000 Binary files a/screenshots/S-E-11-03.jpg and /dev/null differ diff --git a/screenshots/S-E-11-04.jpg b/screenshots/S-E-11-04.jpg deleted file mode 100644 index 88fe2f00..00000000 Binary files a/screenshots/S-E-11-04.jpg and /dev/null differ diff --git a/screenshots/S-E-11.jpg b/screenshots/S-E-11.jpg deleted file mode 100644 index ccf69989..00000000 Binary files a/screenshots/S-E-11.jpg and /dev/null differ diff --git a/screenshots/S-E-12-00.jpg b/screenshots/S-E-12-00.jpg deleted file mode 100644 index ca5081e6..00000000 Binary files a/screenshots/S-E-12-00.jpg and /dev/null differ diff --git a/screenshots/S-E-12-01.jpg b/screenshots/S-E-12-01.jpg deleted file mode 100644 index 941af592..00000000 Binary files a/screenshots/S-E-12-01.jpg and /dev/null differ diff --git a/screenshots/S-E-12-02.jpg b/screenshots/S-E-12-02.jpg deleted file mode 100644 index 0ee38fab..00000000 Binary files a/screenshots/S-E-12-02.jpg and /dev/null differ diff --git a/screenshots/S-E-12-03.jpg b/screenshots/S-E-12-03.jpg deleted file mode 100644 index 5691608f..00000000 Binary files a/screenshots/S-E-12-03.jpg and /dev/null differ diff --git a/screenshots/S-E-12-04.jpg b/screenshots/S-E-12-04.jpg deleted file mode 100644 index 1ae0e43b..00000000 Binary files a/screenshots/S-E-12-04.jpg and /dev/null differ diff --git a/screenshots/S-E-12.jpg b/screenshots/S-E-12.jpg deleted file mode 100644 index 9aa5f259..00000000 Binary files a/screenshots/S-E-12.jpg and /dev/null differ diff --git a/screenshots/S-E-13-00.jpg b/screenshots/S-E-13-00.jpg deleted file mode 100644 index bb0e2ddd..00000000 Binary files a/screenshots/S-E-13-00.jpg and /dev/null differ diff --git a/screenshots/S-E-13-01.jpg b/screenshots/S-E-13-01.jpg deleted file mode 100644 index f505a5fc..00000000 Binary files a/screenshots/S-E-13-01.jpg and /dev/null differ diff --git a/screenshots/S-E-13-02.jpg b/screenshots/S-E-13-02.jpg deleted file mode 100644 index 34a8319d..00000000 Binary files a/screenshots/S-E-13-02.jpg and /dev/null differ diff --git a/screenshots/S-E-13-03.jpg b/screenshots/S-E-13-03.jpg deleted file mode 100644 index 38e03820..00000000 Binary files a/screenshots/S-E-13-03.jpg and /dev/null differ diff --git a/screenshots/S-E-13-04.jpg b/screenshots/S-E-13-04.jpg deleted file mode 100644 index e389203f..00000000 Binary files a/screenshots/S-E-13-04.jpg and /dev/null differ diff --git a/screenshots/S-E-13.jpg b/screenshots/S-E-13.jpg deleted file mode 100644 index 9039a5fa..00000000 Binary files a/screenshots/S-E-13.jpg and /dev/null differ diff --git a/screenshots/S-E-14-00.jpg b/screenshots/S-E-14-00.jpg deleted file mode 100644 index cfdb6fda..00000000 Binary files a/screenshots/S-E-14-00.jpg and /dev/null differ diff --git a/screenshots/S-E-14-01.jpg b/screenshots/S-E-14-01.jpg deleted file mode 100644 index ea26b15c..00000000 Binary files a/screenshots/S-E-14-01.jpg and /dev/null differ diff --git a/screenshots/S-E-14-02.jpg b/screenshots/S-E-14-02.jpg deleted file mode 100644 index 54c6f26f..00000000 Binary files a/screenshots/S-E-14-02.jpg and /dev/null differ diff --git a/screenshots/S-E-14-03.jpg b/screenshots/S-E-14-03.jpg deleted file mode 100644 index 93ba3616..00000000 Binary files a/screenshots/S-E-14-03.jpg and /dev/null differ diff --git a/screenshots/S-E-14-04.jpg b/screenshots/S-E-14-04.jpg deleted file mode 100644 index e9c7791f..00000000 Binary files a/screenshots/S-E-14-04.jpg and /dev/null differ diff --git a/screenshots/S-E-14.jpg b/screenshots/S-E-14.jpg deleted file mode 100644 index 18714d49..00000000 Binary files a/screenshots/S-E-14.jpg and /dev/null differ diff --git a/screenshots/S-E-15-00.jpg b/screenshots/S-E-15-00.jpg deleted file mode 100644 index 43023c27..00000000 Binary files a/screenshots/S-E-15-00.jpg and /dev/null differ diff --git a/screenshots/S-E-15-01.jpg b/screenshots/S-E-15-01.jpg deleted file mode 100644 index 56b6d6af..00000000 Binary files a/screenshots/S-E-15-01.jpg and /dev/null differ diff --git a/screenshots/S-E-15-02.jpg b/screenshots/S-E-15-02.jpg deleted file mode 100644 index e73388e8..00000000 Binary files a/screenshots/S-E-15-02.jpg and /dev/null differ diff --git a/screenshots/S-E-15-03.jpg b/screenshots/S-E-15-03.jpg deleted file mode 100644 index 828066af..00000000 Binary files a/screenshots/S-E-15-03.jpg and /dev/null differ diff --git a/screenshots/S-E-15-04.jpg b/screenshots/S-E-15-04.jpg deleted file mode 100644 index 3c24ecf6..00000000 Binary files a/screenshots/S-E-15-04.jpg and /dev/null differ diff --git a/screenshots/S-E-15.jpg b/screenshots/S-E-15.jpg deleted file mode 100644 index 29bbac1a..00000000 Binary files a/screenshots/S-E-15.jpg and /dev/null differ diff --git a/screenshots/S-E-16-00.jpg b/screenshots/S-E-16-00.jpg deleted file mode 100644 index ec451385..00000000 Binary files a/screenshots/S-E-16-00.jpg and /dev/null differ diff --git a/screenshots/S-E-16-01.jpg b/screenshots/S-E-16-01.jpg deleted file mode 100644 index 08734f9e..00000000 Binary files a/screenshots/S-E-16-01.jpg and /dev/null differ diff --git a/screenshots/S-E-16-02.jpg b/screenshots/S-E-16-02.jpg deleted file mode 100644 index 730206ce..00000000 Binary files a/screenshots/S-E-16-02.jpg and /dev/null differ diff --git a/screenshots/S-E-16-03.jpg b/screenshots/S-E-16-03.jpg deleted file mode 100644 index c56f2eff..00000000 Binary files a/screenshots/S-E-16-03.jpg and /dev/null differ diff --git a/screenshots/S-E-16-04.jpg b/screenshots/S-E-16-04.jpg deleted file mode 100644 index 96debecb..00000000 Binary files a/screenshots/S-E-16-04.jpg and /dev/null differ diff --git a/screenshots/S-E-16.jpg b/screenshots/S-E-16.jpg deleted file mode 100644 index bbefea0f..00000000 Binary files a/screenshots/S-E-16.jpg and /dev/null differ diff --git a/screenshots/S-E-17-00.jpg b/screenshots/S-E-17-00.jpg deleted file mode 100644 index e60227fb..00000000 Binary files a/screenshots/S-E-17-00.jpg and /dev/null differ diff --git a/screenshots/S-E-17-01.jpg b/screenshots/S-E-17-01.jpg deleted file mode 100644 index e733e6eb..00000000 Binary files a/screenshots/S-E-17-01.jpg and /dev/null differ diff --git a/screenshots/S-E-17-02.jpg b/screenshots/S-E-17-02.jpg deleted file mode 100644 index 38349252..00000000 Binary files a/screenshots/S-E-17-02.jpg and /dev/null differ diff --git a/screenshots/S-E-17-03.jpg b/screenshots/S-E-17-03.jpg deleted file mode 100644 index 4fa80eff..00000000 Binary files a/screenshots/S-E-17-03.jpg and /dev/null differ diff --git a/screenshots/S-E-17-04.jpg b/screenshots/S-E-17-04.jpg deleted file mode 100644 index ddd777f3..00000000 Binary files a/screenshots/S-E-17-04.jpg and /dev/null differ diff --git a/screenshots/S-E-17.jpg b/screenshots/S-E-17.jpg deleted file mode 100644 index 7827b4e2..00000000 Binary files a/screenshots/S-E-17.jpg and /dev/null differ diff --git a/screenshots/S-E-18-00.jpg b/screenshots/S-E-18-00.jpg deleted file mode 100644 index 7ee7e909..00000000 Binary files a/screenshots/S-E-18-00.jpg and /dev/null differ diff --git a/screenshots/S-E-18-01.jpg b/screenshots/S-E-18-01.jpg deleted file mode 100644 index 8e23e61c..00000000 Binary files a/screenshots/S-E-18-01.jpg and /dev/null differ diff --git a/screenshots/S-E-18-02.jpg b/screenshots/S-E-18-02.jpg deleted file mode 100644 index 3b5f07ab..00000000 Binary files a/screenshots/S-E-18-02.jpg and /dev/null differ diff --git a/screenshots/S-E-18-03.jpg b/screenshots/S-E-18-03.jpg deleted file mode 100644 index fdbf28d8..00000000 Binary files a/screenshots/S-E-18-03.jpg and /dev/null differ diff --git a/screenshots/S-E-18-04.jpg b/screenshots/S-E-18-04.jpg deleted file mode 100644 index 095b521f..00000000 Binary files a/screenshots/S-E-18-04.jpg and /dev/null differ diff --git a/screenshots/S-E-18.jpg b/screenshots/S-E-18.jpg deleted file mode 100644 index fbcc8f50..00000000 Binary files a/screenshots/S-E-18.jpg and /dev/null differ diff --git a/screenshots/S-E-19-00.jpg b/screenshots/S-E-19-00.jpg deleted file mode 100644 index 8c6dd2e7..00000000 Binary files a/screenshots/S-E-19-00.jpg and /dev/null differ diff --git a/screenshots/S-E-19-01.jpg b/screenshots/S-E-19-01.jpg deleted file mode 100644 index fc6c4099..00000000 Binary files a/screenshots/S-E-19-01.jpg and /dev/null differ diff --git a/screenshots/S-E-19-02.jpg b/screenshots/S-E-19-02.jpg deleted file mode 100644 index 230bda81..00000000 Binary files a/screenshots/S-E-19-02.jpg and /dev/null differ diff --git a/screenshots/S-E-19-03.jpg b/screenshots/S-E-19-03.jpg deleted file mode 100644 index 31683615..00000000 Binary files a/screenshots/S-E-19-03.jpg and /dev/null differ diff --git a/screenshots/S-E-19-04.jpg b/screenshots/S-E-19-04.jpg deleted file mode 100644 index 36993797..00000000 Binary files a/screenshots/S-E-19-04.jpg and /dev/null differ diff --git a/screenshots/S-E-19.jpg b/screenshots/S-E-19.jpg deleted file mode 100644 index 2347bfce..00000000 Binary files a/screenshots/S-E-19.jpg and /dev/null differ diff --git a/screenshots/S-X-00-00.jpg b/screenshots/S-X-00-00.jpg deleted file mode 100644 index 8f1dfc62..00000000 Binary files a/screenshots/S-X-00-00.jpg and /dev/null differ diff --git a/screenshots/S-X-00-01.jpg b/screenshots/S-X-00-01.jpg deleted file mode 100644 index c90c73a0..00000000 Binary files a/screenshots/S-X-00-01.jpg and /dev/null differ diff --git a/screenshots/S-X-00-02.jpg b/screenshots/S-X-00-02.jpg deleted file mode 100644 index f56687b4..00000000 Binary files a/screenshots/S-X-00-02.jpg and /dev/null differ diff --git a/screenshots/S-X-00-03.jpg b/screenshots/S-X-00-03.jpg deleted file mode 100644 index 94374fd7..00000000 Binary files a/screenshots/S-X-00-03.jpg and /dev/null differ diff --git a/screenshots/S-X-00-04.jpg b/screenshots/S-X-00-04.jpg deleted file mode 100644 index 1017cbfe..00000000 Binary files a/screenshots/S-X-00-04.jpg and /dev/null differ diff --git a/screenshots/S-X-00.jpg b/screenshots/S-X-00.jpg deleted file mode 100644 index 7c6d9391..00000000 Binary files a/screenshots/S-X-00.jpg and /dev/null differ diff --git a/screenshots/S-X-01-00.jpg b/screenshots/S-X-01-00.jpg deleted file mode 100644 index 00afc01c..00000000 Binary files a/screenshots/S-X-01-00.jpg and /dev/null differ diff --git a/screenshots/S-X-01-01.jpg b/screenshots/S-X-01-01.jpg deleted file mode 100644 index 96d23955..00000000 Binary files a/screenshots/S-X-01-01.jpg and /dev/null differ diff --git a/screenshots/S-X-01-02.jpg b/screenshots/S-X-01-02.jpg deleted file mode 100644 index b255f18a..00000000 Binary files a/screenshots/S-X-01-02.jpg and /dev/null differ diff --git a/screenshots/S-X-01-03.jpg b/screenshots/S-X-01-03.jpg deleted file mode 100644 index 91c3a9f8..00000000 Binary files a/screenshots/S-X-01-03.jpg and /dev/null differ diff --git a/screenshots/S-X-01-04.jpg b/screenshots/S-X-01-04.jpg deleted file mode 100644 index b0e5963c..00000000 Binary files a/screenshots/S-X-01-04.jpg and /dev/null differ diff --git a/screenshots/S-X-01.jpg b/screenshots/S-X-01.jpg deleted file mode 100644 index 6cc0700c..00000000 Binary files a/screenshots/S-X-01.jpg and /dev/null differ diff --git a/screenshots/S-X-02-00.jpg b/screenshots/S-X-02-00.jpg deleted file mode 100644 index f98571d4..00000000 Binary files a/screenshots/S-X-02-00.jpg and /dev/null differ diff --git a/screenshots/S-X-02-01.jpg b/screenshots/S-X-02-01.jpg deleted file mode 100644 index dab16320..00000000 Binary files a/screenshots/S-X-02-01.jpg and /dev/null differ diff --git a/screenshots/S-X-02-02.jpg b/screenshots/S-X-02-02.jpg deleted file mode 100644 index 3dbd96fd..00000000 Binary files a/screenshots/S-X-02-02.jpg and /dev/null differ diff --git a/screenshots/S-X-02-03.jpg b/screenshots/S-X-02-03.jpg deleted file mode 100644 index 788f409f..00000000 Binary files a/screenshots/S-X-02-03.jpg and /dev/null differ diff --git a/screenshots/S-X-02-04.jpg b/screenshots/S-X-02-04.jpg deleted file mode 100644 index a78578c5..00000000 Binary files a/screenshots/S-X-02-04.jpg and /dev/null differ diff --git a/screenshots/S-X-02.jpg b/screenshots/S-X-02.jpg deleted file mode 100644 index 89c416e2..00000000 Binary files a/screenshots/S-X-02.jpg and /dev/null differ diff --git a/screenshots/S-X-03-00.jpg b/screenshots/S-X-03-00.jpg deleted file mode 100644 index 0bd19282..00000000 Binary files a/screenshots/S-X-03-00.jpg and /dev/null differ diff --git a/screenshots/S-X-03-01.jpg b/screenshots/S-X-03-01.jpg deleted file mode 100644 index 0b31ffd9..00000000 Binary files a/screenshots/S-X-03-01.jpg and /dev/null differ diff --git a/screenshots/S-X-03-02.jpg b/screenshots/S-X-03-02.jpg deleted file mode 100644 index 218f2f93..00000000 Binary files a/screenshots/S-X-03-02.jpg and /dev/null differ diff --git a/screenshots/S-X-03-03.jpg b/screenshots/S-X-03-03.jpg deleted file mode 100644 index cffe6b3d..00000000 Binary files a/screenshots/S-X-03-03.jpg and /dev/null differ diff --git a/screenshots/S-X-03-04.jpg b/screenshots/S-X-03-04.jpg deleted file mode 100644 index 0231418f..00000000 Binary files a/screenshots/S-X-03-04.jpg and /dev/null differ diff --git a/screenshots/S-X-03.jpg b/screenshots/S-X-03.jpg deleted file mode 100644 index 121b594a..00000000 Binary files a/screenshots/S-X-03.jpg and /dev/null differ diff --git a/screenshots/S-X-04-00.jpg b/screenshots/S-X-04-00.jpg deleted file mode 100644 index b280e1b3..00000000 Binary files a/screenshots/S-X-04-00.jpg and /dev/null differ diff --git a/screenshots/S-X-04-01.jpg b/screenshots/S-X-04-01.jpg deleted file mode 100644 index 2e81ff90..00000000 Binary files a/screenshots/S-X-04-01.jpg and /dev/null differ diff --git a/screenshots/S-X-04-02.jpg b/screenshots/S-X-04-02.jpg deleted file mode 100644 index 9ab22386..00000000 Binary files a/screenshots/S-X-04-02.jpg and /dev/null differ diff --git a/screenshots/S-X-04-03.jpg b/screenshots/S-X-04-03.jpg deleted file mode 100644 index aa439229..00000000 Binary files a/screenshots/S-X-04-03.jpg and /dev/null differ diff --git a/screenshots/S-X-04-04.jpg b/screenshots/S-X-04-04.jpg deleted file mode 100644 index c4123d5e..00000000 Binary files a/screenshots/S-X-04-04.jpg and /dev/null differ diff --git a/screenshots/S-X-04.jpg b/screenshots/S-X-04.jpg deleted file mode 100644 index 28e4b0bb..00000000 Binary files a/screenshots/S-X-04.jpg and /dev/null differ diff --git a/screenshots/S-X-05-00.jpg b/screenshots/S-X-05-00.jpg deleted file mode 100644 index 18d05e03..00000000 Binary files a/screenshots/S-X-05-00.jpg and /dev/null differ diff --git a/screenshots/S-X-05-01.jpg b/screenshots/S-X-05-01.jpg deleted file mode 100644 index 886f4fde..00000000 Binary files a/screenshots/S-X-05-01.jpg and /dev/null differ diff --git a/screenshots/S-X-05-02.jpg b/screenshots/S-X-05-02.jpg deleted file mode 100644 index 5d1065f0..00000000 Binary files a/screenshots/S-X-05-02.jpg and /dev/null differ diff --git a/screenshots/S-X-05-03.jpg b/screenshots/S-X-05-03.jpg deleted file mode 100644 index 8f2543a0..00000000 Binary files a/screenshots/S-X-05-03.jpg and /dev/null differ diff --git a/screenshots/S-X-05-04.jpg b/screenshots/S-X-05-04.jpg deleted file mode 100644 index 6c063955..00000000 Binary files a/screenshots/S-X-05-04.jpg and /dev/null differ diff --git a/screenshots/S-X-05.jpg b/screenshots/S-X-05.jpg deleted file mode 100644 index 26ad86dd..00000000 Binary files a/screenshots/S-X-05.jpg and /dev/null differ diff --git a/screenshots/S-X-06-00.jpg b/screenshots/S-X-06-00.jpg deleted file mode 100644 index 22403e33..00000000 Binary files a/screenshots/S-X-06-00.jpg and /dev/null differ diff --git a/screenshots/S-X-06-01.jpg b/screenshots/S-X-06-01.jpg deleted file mode 100644 index eee74728..00000000 Binary files a/screenshots/S-X-06-01.jpg and /dev/null differ diff --git a/screenshots/S-X-06-02.jpg b/screenshots/S-X-06-02.jpg deleted file mode 100644 index e5172bd8..00000000 Binary files a/screenshots/S-X-06-02.jpg and /dev/null differ diff --git a/screenshots/S-X-06-03.jpg b/screenshots/S-X-06-03.jpg deleted file mode 100644 index 734c768d..00000000 Binary files a/screenshots/S-X-06-03.jpg and /dev/null differ diff --git a/screenshots/S-X-06-04.jpg b/screenshots/S-X-06-04.jpg deleted file mode 100644 index 8ee77a20..00000000 Binary files a/screenshots/S-X-06-04.jpg and /dev/null differ diff --git a/screenshots/S-X-06.jpg b/screenshots/S-X-06.jpg deleted file mode 100644 index e95c7e6d..00000000 Binary files a/screenshots/S-X-06.jpg and /dev/null differ diff --git a/screenshots/S-X-07-00.jpg b/screenshots/S-X-07-00.jpg deleted file mode 100644 index e3fa30b8..00000000 Binary files a/screenshots/S-X-07-00.jpg and /dev/null differ diff --git a/screenshots/S-X-07-01.jpg b/screenshots/S-X-07-01.jpg deleted file mode 100644 index 533a4e46..00000000 Binary files a/screenshots/S-X-07-01.jpg and /dev/null differ diff --git a/screenshots/S-X-07-02.jpg b/screenshots/S-X-07-02.jpg deleted file mode 100644 index f9daabfa..00000000 Binary files a/screenshots/S-X-07-02.jpg and /dev/null differ diff --git a/screenshots/S-X-07-03.jpg b/screenshots/S-X-07-03.jpg deleted file mode 100644 index e53a9bcb..00000000 Binary files a/screenshots/S-X-07-03.jpg and /dev/null differ diff --git a/screenshots/S-X-07-04.jpg b/screenshots/S-X-07-04.jpg deleted file mode 100644 index c76048d9..00000000 Binary files a/screenshots/S-X-07-04.jpg and /dev/null differ diff --git a/screenshots/S-X-07.jpg b/screenshots/S-X-07.jpg deleted file mode 100644 index 7171acc4..00000000 Binary files a/screenshots/S-X-07.jpg and /dev/null differ diff --git a/screenshots/S-X-08-00.jpg b/screenshots/S-X-08-00.jpg deleted file mode 100644 index 76e254a6..00000000 Binary files a/screenshots/S-X-08-00.jpg and /dev/null differ diff --git a/screenshots/S-X-08-01.jpg b/screenshots/S-X-08-01.jpg deleted file mode 100644 index abcb093a..00000000 Binary files a/screenshots/S-X-08-01.jpg and /dev/null differ diff --git a/screenshots/S-X-08-02.jpg b/screenshots/S-X-08-02.jpg deleted file mode 100644 index 75515459..00000000 Binary files a/screenshots/S-X-08-02.jpg and /dev/null differ diff --git a/screenshots/S-X-08-03.jpg b/screenshots/S-X-08-03.jpg deleted file mode 100644 index 06976e26..00000000 Binary files a/screenshots/S-X-08-03.jpg and /dev/null differ diff --git a/screenshots/S-X-08-04.jpg b/screenshots/S-X-08-04.jpg deleted file mode 100644 index 359d6016..00000000 Binary files a/screenshots/S-X-08-04.jpg and /dev/null differ diff --git a/screenshots/S-X-08.jpg b/screenshots/S-X-08.jpg deleted file mode 100644 index 14425a2b..00000000 Binary files a/screenshots/S-X-08.jpg and /dev/null differ diff --git a/screenshots/S-X-09-00.jpg b/screenshots/S-X-09-00.jpg deleted file mode 100644 index c1989eea..00000000 Binary files a/screenshots/S-X-09-00.jpg and /dev/null differ diff --git a/screenshots/S-X-09-01.jpg b/screenshots/S-X-09-01.jpg deleted file mode 100644 index 8c8c2110..00000000 Binary files a/screenshots/S-X-09-01.jpg and /dev/null differ diff --git a/screenshots/S-X-09-02.jpg b/screenshots/S-X-09-02.jpg deleted file mode 100644 index 32651d18..00000000 Binary files a/screenshots/S-X-09-02.jpg and /dev/null differ diff --git a/screenshots/S-X-09-03.jpg b/screenshots/S-X-09-03.jpg deleted file mode 100644 index bb4bbc05..00000000 Binary files a/screenshots/S-X-09-03.jpg and /dev/null differ diff --git a/screenshots/S-X-09-04.jpg b/screenshots/S-X-09-04.jpg deleted file mode 100644 index 2b2b243d..00000000 Binary files a/screenshots/S-X-09-04.jpg and /dev/null differ diff --git a/screenshots/S-X-09.jpg b/screenshots/S-X-09.jpg deleted file mode 100644 index 58621664..00000000 Binary files a/screenshots/S-X-09.jpg and /dev/null differ diff --git a/screenshots/S-X-10-00.jpg b/screenshots/S-X-10-00.jpg deleted file mode 100644 index c7219257..00000000 Binary files a/screenshots/S-X-10-00.jpg and /dev/null differ diff --git a/screenshots/S-X-10-01.jpg b/screenshots/S-X-10-01.jpg deleted file mode 100644 index 67753989..00000000 Binary files a/screenshots/S-X-10-01.jpg and /dev/null differ diff --git a/screenshots/S-X-10-02.jpg b/screenshots/S-X-10-02.jpg deleted file mode 100644 index 0a4a099b..00000000 Binary files a/screenshots/S-X-10-02.jpg and /dev/null differ diff --git a/screenshots/S-X-10-03.jpg b/screenshots/S-X-10-03.jpg deleted file mode 100644 index d21686a4..00000000 Binary files a/screenshots/S-X-10-03.jpg and /dev/null differ diff --git a/screenshots/S-X-10-04.jpg b/screenshots/S-X-10-04.jpg deleted file mode 100644 index 273780e3..00000000 Binary files a/screenshots/S-X-10-04.jpg and /dev/null differ diff --git a/screenshots/S-X-10.jpg b/screenshots/S-X-10.jpg deleted file mode 100644 index c6c2445e..00000000 Binary files a/screenshots/S-X-10.jpg and /dev/null differ diff --git a/screenshots/S-X-11-00.jpg b/screenshots/S-X-11-00.jpg deleted file mode 100644 index a3b340c1..00000000 Binary files a/screenshots/S-X-11-00.jpg and /dev/null differ diff --git a/screenshots/S-X-11-01.jpg b/screenshots/S-X-11-01.jpg deleted file mode 100644 index ac32942e..00000000 Binary files a/screenshots/S-X-11-01.jpg and /dev/null differ diff --git a/screenshots/S-X-11-02.jpg b/screenshots/S-X-11-02.jpg deleted file mode 100644 index 319159c5..00000000 Binary files a/screenshots/S-X-11-02.jpg and /dev/null differ diff --git a/screenshots/S-X-11-03.jpg b/screenshots/S-X-11-03.jpg deleted file mode 100644 index 47b28ca5..00000000 Binary files a/screenshots/S-X-11-03.jpg and /dev/null differ diff --git a/screenshots/S-X-11-04.jpg b/screenshots/S-X-11-04.jpg deleted file mode 100644 index 8d1a76e1..00000000 Binary files a/screenshots/S-X-11-04.jpg and /dev/null differ diff --git a/screenshots/S-X-11.jpg b/screenshots/S-X-11.jpg deleted file mode 100644 index 25a7f621..00000000 Binary files a/screenshots/S-X-11.jpg and /dev/null differ diff --git a/screenshots/S-X-12-00.jpg b/screenshots/S-X-12-00.jpg deleted file mode 100644 index c026665b..00000000 Binary files a/screenshots/S-X-12-00.jpg and /dev/null differ diff --git a/screenshots/S-X-12-01.jpg b/screenshots/S-X-12-01.jpg deleted file mode 100644 index e63cddf3..00000000 Binary files a/screenshots/S-X-12-01.jpg and /dev/null differ diff --git a/screenshots/S-X-12-02.jpg b/screenshots/S-X-12-02.jpg deleted file mode 100644 index 7d208352..00000000 Binary files a/screenshots/S-X-12-02.jpg and /dev/null differ diff --git a/screenshots/S-X-12-03.jpg b/screenshots/S-X-12-03.jpg deleted file mode 100644 index 8b718b24..00000000 Binary files a/screenshots/S-X-12-03.jpg and /dev/null differ diff --git a/screenshots/S-X-12-04.jpg b/screenshots/S-X-12-04.jpg deleted file mode 100644 index b70f0672..00000000 Binary files a/screenshots/S-X-12-04.jpg and /dev/null differ diff --git a/screenshots/S-X-12.jpg b/screenshots/S-X-12.jpg deleted file mode 100644 index 252446bc..00000000 Binary files a/screenshots/S-X-12.jpg and /dev/null differ diff --git a/screenshots/S-X-13-00.jpg b/screenshots/S-X-13-00.jpg deleted file mode 100644 index dbd55358..00000000 Binary files a/screenshots/S-X-13-00.jpg and /dev/null differ diff --git a/screenshots/S-X-13-01.jpg b/screenshots/S-X-13-01.jpg deleted file mode 100644 index 6cd99dfd..00000000 Binary files a/screenshots/S-X-13-01.jpg and /dev/null differ diff --git a/screenshots/S-X-13-02.jpg b/screenshots/S-X-13-02.jpg deleted file mode 100644 index 225b392d..00000000 Binary files a/screenshots/S-X-13-02.jpg and /dev/null differ diff --git a/screenshots/S-X-13-03.jpg b/screenshots/S-X-13-03.jpg deleted file mode 100644 index ec5114b8..00000000 Binary files a/screenshots/S-X-13-03.jpg and /dev/null differ diff --git a/screenshots/S-X-13-04.jpg b/screenshots/S-X-13-04.jpg deleted file mode 100644 index 0c56258d..00000000 Binary files a/screenshots/S-X-13-04.jpg and /dev/null differ diff --git a/screenshots/S-X-13.jpg b/screenshots/S-X-13.jpg deleted file mode 100644 index 1b3592c4..00000000 Binary files a/screenshots/S-X-13.jpg and /dev/null differ diff --git a/screenshots/S-X-14-00.jpg b/screenshots/S-X-14-00.jpg deleted file mode 100644 index e2ab1757..00000000 Binary files a/screenshots/S-X-14-00.jpg and /dev/null differ diff --git a/screenshots/S-X-14-01.jpg b/screenshots/S-X-14-01.jpg deleted file mode 100644 index c896548a..00000000 Binary files a/screenshots/S-X-14-01.jpg and /dev/null differ diff --git a/screenshots/S-X-14-02.jpg b/screenshots/S-X-14-02.jpg deleted file mode 100644 index 87370d48..00000000 Binary files a/screenshots/S-X-14-02.jpg and /dev/null differ diff --git a/screenshots/S-X-14-03.jpg b/screenshots/S-X-14-03.jpg deleted file mode 100644 index 88c9a519..00000000 Binary files a/screenshots/S-X-14-03.jpg and /dev/null differ diff --git a/screenshots/S-X-14-04.jpg b/screenshots/S-X-14-04.jpg deleted file mode 100644 index fbbf989b..00000000 Binary files a/screenshots/S-X-14-04.jpg and /dev/null differ diff --git a/screenshots/S-X-14.jpg b/screenshots/S-X-14.jpg deleted file mode 100644 index 6d91a52d..00000000 Binary files a/screenshots/S-X-14.jpg and /dev/null differ diff --git a/screenshots/S-X-15-00.jpg b/screenshots/S-X-15-00.jpg deleted file mode 100644 index 48f491bb..00000000 Binary files a/screenshots/S-X-15-00.jpg and /dev/null differ diff --git a/screenshots/S-X-15-01.jpg b/screenshots/S-X-15-01.jpg deleted file mode 100644 index cf781a29..00000000 Binary files a/screenshots/S-X-15-01.jpg and /dev/null differ diff --git a/screenshots/S-X-15-02.jpg b/screenshots/S-X-15-02.jpg deleted file mode 100644 index 88d421c7..00000000 Binary files a/screenshots/S-X-15-02.jpg and /dev/null differ diff --git a/screenshots/S-X-15-03.jpg b/screenshots/S-X-15-03.jpg deleted file mode 100644 index 3ead7a1b..00000000 Binary files a/screenshots/S-X-15-03.jpg and /dev/null differ diff --git a/screenshots/S-X-15-04.jpg b/screenshots/S-X-15-04.jpg deleted file mode 100644 index af076398..00000000 Binary files a/screenshots/S-X-15-04.jpg and /dev/null differ diff --git a/screenshots/S-X-15.jpg b/screenshots/S-X-15.jpg deleted file mode 100644 index 2b4d13ce..00000000 Binary files a/screenshots/S-X-15.jpg and /dev/null differ diff --git a/screenshots/S-X-16-00.jpg b/screenshots/S-X-16-00.jpg deleted file mode 100644 index f49bba8e..00000000 Binary files a/screenshots/S-X-16-00.jpg and /dev/null differ diff --git a/screenshots/S-X-16-01.jpg b/screenshots/S-X-16-01.jpg deleted file mode 100644 index 31865f03..00000000 Binary files a/screenshots/S-X-16-01.jpg and /dev/null differ diff --git a/screenshots/S-X-16-02.jpg b/screenshots/S-X-16-02.jpg deleted file mode 100644 index d62b96fe..00000000 Binary files a/screenshots/S-X-16-02.jpg and /dev/null differ diff --git a/screenshots/S-X-16-03.jpg b/screenshots/S-X-16-03.jpg deleted file mode 100644 index 4596c67a..00000000 Binary files a/screenshots/S-X-16-03.jpg and /dev/null differ diff --git a/screenshots/S-X-16-04.jpg b/screenshots/S-X-16-04.jpg deleted file mode 100644 index e025ff97..00000000 Binary files a/screenshots/S-X-16-04.jpg and /dev/null differ diff --git a/screenshots/S-X-16.jpg b/screenshots/S-X-16.jpg deleted file mode 100644 index 91c4eca2..00000000 Binary files a/screenshots/S-X-16.jpg and /dev/null differ diff --git a/screenshots/S-X-17-00.jpg b/screenshots/S-X-17-00.jpg deleted file mode 100644 index 6bb1cc7d..00000000 Binary files a/screenshots/S-X-17-00.jpg and /dev/null differ diff --git a/screenshots/S-X-17-01.jpg b/screenshots/S-X-17-01.jpg deleted file mode 100644 index c617db5a..00000000 Binary files a/screenshots/S-X-17-01.jpg and /dev/null differ diff --git a/screenshots/S-X-17-02.jpg b/screenshots/S-X-17-02.jpg deleted file mode 100644 index f8efe151..00000000 Binary files a/screenshots/S-X-17-02.jpg and /dev/null differ diff --git a/screenshots/S-X-17-03.jpg b/screenshots/S-X-17-03.jpg deleted file mode 100644 index f6a34b5b..00000000 Binary files a/screenshots/S-X-17-03.jpg and /dev/null differ diff --git a/screenshots/S-X-17-04.jpg b/screenshots/S-X-17-04.jpg deleted file mode 100644 index 1b77c045..00000000 Binary files a/screenshots/S-X-17-04.jpg and /dev/null differ diff --git a/screenshots/S-X-17.jpg b/screenshots/S-X-17.jpg deleted file mode 100644 index a520fc29..00000000 Binary files a/screenshots/S-X-17.jpg and /dev/null differ diff --git a/screenshots/S-X-18-00.jpg b/screenshots/S-X-18-00.jpg deleted file mode 100644 index befc5b80..00000000 Binary files a/screenshots/S-X-18-00.jpg and /dev/null differ diff --git a/screenshots/S-X-18-01.jpg b/screenshots/S-X-18-01.jpg deleted file mode 100644 index d3dbe4c8..00000000 Binary files a/screenshots/S-X-18-01.jpg and /dev/null differ diff --git a/screenshots/S-X-18-02.jpg b/screenshots/S-X-18-02.jpg deleted file mode 100644 index 07bb8e1c..00000000 Binary files a/screenshots/S-X-18-02.jpg and /dev/null differ diff --git a/screenshots/S-X-18-03.jpg b/screenshots/S-X-18-03.jpg deleted file mode 100644 index 0084d7b2..00000000 Binary files a/screenshots/S-X-18-03.jpg and /dev/null differ diff --git a/screenshots/S-X-18-04.jpg b/screenshots/S-X-18-04.jpg deleted file mode 100644 index 7fd30a61..00000000 Binary files a/screenshots/S-X-18-04.jpg and /dev/null differ diff --git a/screenshots/S-X-18.jpg b/screenshots/S-X-18.jpg deleted file mode 100644 index e0134203..00000000 Binary files a/screenshots/S-X-18.jpg and /dev/null differ diff --git a/screenshots/S-X-19-00.jpg b/screenshots/S-X-19-00.jpg deleted file mode 100644 index 591abda4..00000000 Binary files a/screenshots/S-X-19-00.jpg and /dev/null differ diff --git a/screenshots/S-X-19-01.jpg b/screenshots/S-X-19-01.jpg deleted file mode 100644 index 272fd17a..00000000 Binary files a/screenshots/S-X-19-01.jpg and /dev/null differ diff --git a/screenshots/S-X-19-02.jpg b/screenshots/S-X-19-02.jpg deleted file mode 100644 index dd4dc5d7..00000000 Binary files a/screenshots/S-X-19-02.jpg and /dev/null differ diff --git a/screenshots/S-X-19-03.jpg b/screenshots/S-X-19-03.jpg deleted file mode 100644 index 483367df..00000000 Binary files a/screenshots/S-X-19-03.jpg and /dev/null differ diff --git a/screenshots/S-X-19-04.jpg b/screenshots/S-X-19-04.jpg deleted file mode 100644 index 2553d4ad..00000000 Binary files a/screenshots/S-X-19-04.jpg and /dev/null differ diff --git a/screenshots/S-X-19.jpg b/screenshots/S-X-19.jpg deleted file mode 100644 index 824573a0..00000000 Binary files a/screenshots/S-X-19.jpg and /dev/null differ diff --git a/screenshots/SI-A-00-00.jpg b/screenshots/SI-A-00-00.jpg deleted file mode 100644 index 3b661e46..00000000 Binary files a/screenshots/SI-A-00-00.jpg and /dev/null differ diff --git a/screenshots/SI-A-00-01.jpg b/screenshots/SI-A-00-01.jpg deleted file mode 100644 index c96239de..00000000 Binary files a/screenshots/SI-A-00-01.jpg and /dev/null differ diff --git a/screenshots/SI-A-00-02.jpg b/screenshots/SI-A-00-02.jpg deleted file mode 100644 index 7264a6d1..00000000 Binary files a/screenshots/SI-A-00-02.jpg and /dev/null differ diff --git a/screenshots/SI-A-00-03.jpg b/screenshots/SI-A-00-03.jpg deleted file mode 100644 index fabc09f4..00000000 Binary files a/screenshots/SI-A-00-03.jpg and /dev/null differ diff --git a/screenshots/SI-A-00-04.jpg b/screenshots/SI-A-00-04.jpg deleted file mode 100644 index 9b2ec6c5..00000000 Binary files a/screenshots/SI-A-00-04.jpg and /dev/null differ diff --git a/screenshots/SI-A-00.jpg b/screenshots/SI-A-00.jpg deleted file mode 100644 index 7124d573..00000000 Binary files a/screenshots/SI-A-00.jpg and /dev/null differ diff --git a/screenshots/SI-A-01-00.jpg b/screenshots/SI-A-01-00.jpg deleted file mode 100644 index 8db5405e..00000000 Binary files a/screenshots/SI-A-01-00.jpg and /dev/null differ diff --git a/screenshots/SI-A-01-01.jpg b/screenshots/SI-A-01-01.jpg deleted file mode 100644 index c20867ec..00000000 Binary files a/screenshots/SI-A-01-01.jpg and /dev/null differ diff --git a/screenshots/SI-A-01-02.jpg b/screenshots/SI-A-01-02.jpg deleted file mode 100644 index bbcf410e..00000000 Binary files a/screenshots/SI-A-01-02.jpg and /dev/null differ diff --git a/screenshots/SI-A-01-03.jpg b/screenshots/SI-A-01-03.jpg deleted file mode 100644 index d53cec54..00000000 Binary files a/screenshots/SI-A-01-03.jpg and /dev/null differ diff --git a/screenshots/SI-A-01-04.jpg b/screenshots/SI-A-01-04.jpg deleted file mode 100644 index 1ae0e2a4..00000000 Binary files a/screenshots/SI-A-01-04.jpg and /dev/null differ diff --git a/screenshots/SI-A-01.jpg b/screenshots/SI-A-01.jpg deleted file mode 100644 index 0e2e84f2..00000000 Binary files a/screenshots/SI-A-01.jpg and /dev/null differ diff --git a/screenshots/SI-A-02-00.jpg b/screenshots/SI-A-02-00.jpg deleted file mode 100644 index f0bfdbe7..00000000 Binary files a/screenshots/SI-A-02-00.jpg and /dev/null differ diff --git a/screenshots/SI-A-02-01.jpg b/screenshots/SI-A-02-01.jpg deleted file mode 100644 index 2a17b557..00000000 Binary files a/screenshots/SI-A-02-01.jpg and /dev/null differ diff --git a/screenshots/SI-A-02-02.jpg b/screenshots/SI-A-02-02.jpg deleted file mode 100644 index 7f022eb0..00000000 Binary files a/screenshots/SI-A-02-02.jpg and /dev/null differ diff --git a/screenshots/SI-A-02-03.jpg b/screenshots/SI-A-02-03.jpg deleted file mode 100644 index 10322198..00000000 Binary files a/screenshots/SI-A-02-03.jpg and /dev/null differ diff --git a/screenshots/SI-A-02-04.jpg b/screenshots/SI-A-02-04.jpg deleted file mode 100644 index 79cc3bfb..00000000 Binary files a/screenshots/SI-A-02-04.jpg and /dev/null differ diff --git a/screenshots/SI-A-02.jpg b/screenshots/SI-A-02.jpg deleted file mode 100644 index 9470000e..00000000 Binary files a/screenshots/SI-A-02.jpg and /dev/null differ diff --git a/screenshots/SI-A-03-00.jpg b/screenshots/SI-A-03-00.jpg deleted file mode 100644 index ea6363c8..00000000 Binary files a/screenshots/SI-A-03-00.jpg and /dev/null differ diff --git a/screenshots/SI-A-03-01.jpg b/screenshots/SI-A-03-01.jpg deleted file mode 100644 index 9b2be45f..00000000 Binary files a/screenshots/SI-A-03-01.jpg and /dev/null differ diff --git a/screenshots/SI-A-03-02.jpg b/screenshots/SI-A-03-02.jpg deleted file mode 100644 index 0fb8932b..00000000 Binary files a/screenshots/SI-A-03-02.jpg and /dev/null differ diff --git a/screenshots/SI-A-03-03.jpg b/screenshots/SI-A-03-03.jpg deleted file mode 100644 index a4421ffe..00000000 Binary files a/screenshots/SI-A-03-03.jpg and /dev/null differ diff --git a/screenshots/SI-A-03-04.jpg b/screenshots/SI-A-03-04.jpg deleted file mode 100644 index 0574af14..00000000 Binary files a/screenshots/SI-A-03-04.jpg and /dev/null differ diff --git a/screenshots/SI-A-03.jpg b/screenshots/SI-A-03.jpg deleted file mode 100644 index 88d9aa6d..00000000 Binary files a/screenshots/SI-A-03.jpg and /dev/null differ diff --git a/screenshots/SI-A-04-00.jpg b/screenshots/SI-A-04-00.jpg deleted file mode 100644 index 0ccb7b6e..00000000 Binary files a/screenshots/SI-A-04-00.jpg and /dev/null differ diff --git a/screenshots/SI-A-04-01.jpg b/screenshots/SI-A-04-01.jpg deleted file mode 100644 index 4e66379b..00000000 Binary files a/screenshots/SI-A-04-01.jpg and /dev/null differ diff --git a/screenshots/SI-A-04-02.jpg b/screenshots/SI-A-04-02.jpg deleted file mode 100644 index 832daf48..00000000 Binary files a/screenshots/SI-A-04-02.jpg and /dev/null differ diff --git a/screenshots/SI-A-04-03.jpg b/screenshots/SI-A-04-03.jpg deleted file mode 100644 index f9821314..00000000 Binary files a/screenshots/SI-A-04-03.jpg and /dev/null differ diff --git a/screenshots/SI-A-04-04.jpg b/screenshots/SI-A-04-04.jpg deleted file mode 100644 index 7189de99..00000000 Binary files a/screenshots/SI-A-04-04.jpg and /dev/null differ diff --git a/screenshots/SI-A-04.jpg b/screenshots/SI-A-04.jpg deleted file mode 100644 index 6d64c714..00000000 Binary files a/screenshots/SI-A-04.jpg and /dev/null differ diff --git a/screenshots/SI-B-00-00.jpg b/screenshots/SI-B-00-00.jpg deleted file mode 100644 index fc332642..00000000 Binary files a/screenshots/SI-B-00-00.jpg and /dev/null differ diff --git a/screenshots/SI-B-00-01.jpg b/screenshots/SI-B-00-01.jpg deleted file mode 100644 index ab6d78eb..00000000 Binary files a/screenshots/SI-B-00-01.jpg and /dev/null differ diff --git a/screenshots/SI-B-00-02.jpg b/screenshots/SI-B-00-02.jpg deleted file mode 100644 index c8d92ba3..00000000 Binary files a/screenshots/SI-B-00-02.jpg and /dev/null differ diff --git a/screenshots/SI-B-00-03.jpg b/screenshots/SI-B-00-03.jpg deleted file mode 100644 index 7cf4aa6e..00000000 Binary files a/screenshots/SI-B-00-03.jpg and /dev/null differ diff --git a/screenshots/SI-B-00-04.jpg b/screenshots/SI-B-00-04.jpg deleted file mode 100644 index a23323de..00000000 Binary files a/screenshots/SI-B-00-04.jpg and /dev/null differ diff --git a/screenshots/SI-B-00.jpg b/screenshots/SI-B-00.jpg deleted file mode 100644 index 78c7870b..00000000 Binary files a/screenshots/SI-B-00.jpg and /dev/null differ diff --git a/screenshots/SI-B-01-00.jpg b/screenshots/SI-B-01-00.jpg deleted file mode 100644 index 40ba131e..00000000 Binary files a/screenshots/SI-B-01-00.jpg and /dev/null differ diff --git a/screenshots/SI-B-01-01.jpg b/screenshots/SI-B-01-01.jpg deleted file mode 100644 index d44e003d..00000000 Binary files a/screenshots/SI-B-01-01.jpg and /dev/null differ diff --git a/screenshots/SI-B-01-02.jpg b/screenshots/SI-B-01-02.jpg deleted file mode 100644 index ec9bdfbe..00000000 Binary files a/screenshots/SI-B-01-02.jpg and /dev/null differ diff --git a/screenshots/SI-B-01-03.jpg b/screenshots/SI-B-01-03.jpg deleted file mode 100644 index b81d9618..00000000 Binary files a/screenshots/SI-B-01-03.jpg and /dev/null differ diff --git a/screenshots/SI-B-01-04.jpg b/screenshots/SI-B-01-04.jpg deleted file mode 100644 index d964dc6c..00000000 Binary files a/screenshots/SI-B-01-04.jpg and /dev/null differ diff --git a/screenshots/SI-B-01.jpg b/screenshots/SI-B-01.jpg deleted file mode 100644 index 93d84fd8..00000000 Binary files a/screenshots/SI-B-01.jpg and /dev/null differ diff --git a/screenshots/SI-B-02-00.jpg b/screenshots/SI-B-02-00.jpg deleted file mode 100644 index 6dabf7c0..00000000 Binary files a/screenshots/SI-B-02-00.jpg and /dev/null differ diff --git a/screenshots/SI-B-02-01.jpg b/screenshots/SI-B-02-01.jpg deleted file mode 100644 index eba581af..00000000 Binary files a/screenshots/SI-B-02-01.jpg and /dev/null differ diff --git a/screenshots/SI-B-02-02.jpg b/screenshots/SI-B-02-02.jpg deleted file mode 100644 index 4991da76..00000000 Binary files a/screenshots/SI-B-02-02.jpg and /dev/null differ diff --git a/screenshots/SI-B-02-03.jpg b/screenshots/SI-B-02-03.jpg deleted file mode 100644 index dc0ff7c0..00000000 Binary files a/screenshots/SI-B-02-03.jpg and /dev/null differ diff --git a/screenshots/SI-B-02-04.jpg b/screenshots/SI-B-02-04.jpg deleted file mode 100644 index 8542ba3a..00000000 Binary files a/screenshots/SI-B-02-04.jpg and /dev/null differ diff --git a/screenshots/SI-B-02.jpg b/screenshots/SI-B-02.jpg deleted file mode 100644 index ec508d16..00000000 Binary files a/screenshots/SI-B-02.jpg and /dev/null differ diff --git a/screenshots/SI-B-03-00.jpg b/screenshots/SI-B-03-00.jpg deleted file mode 100644 index 8bf2c196..00000000 Binary files a/screenshots/SI-B-03-00.jpg and /dev/null differ diff --git a/screenshots/SI-B-03-01.jpg b/screenshots/SI-B-03-01.jpg deleted file mode 100644 index f8e30026..00000000 Binary files a/screenshots/SI-B-03-01.jpg and /dev/null differ diff --git a/screenshots/SI-B-03-02.jpg b/screenshots/SI-B-03-02.jpg deleted file mode 100644 index b199a440..00000000 Binary files a/screenshots/SI-B-03-02.jpg and /dev/null differ diff --git a/screenshots/SI-B-03-03.jpg b/screenshots/SI-B-03-03.jpg deleted file mode 100644 index 1b9ff8f5..00000000 Binary files a/screenshots/SI-B-03-03.jpg and /dev/null differ diff --git a/screenshots/SI-B-03-04.jpg b/screenshots/SI-B-03-04.jpg deleted file mode 100644 index 0009fc73..00000000 Binary files a/screenshots/SI-B-03-04.jpg and /dev/null differ diff --git a/screenshots/SI-B-03.jpg b/screenshots/SI-B-03.jpg deleted file mode 100644 index 2ad0a28b..00000000 Binary files a/screenshots/SI-B-03.jpg and /dev/null differ diff --git a/screenshots/SI-B-04-00.jpg b/screenshots/SI-B-04-00.jpg deleted file mode 100644 index 919a6882..00000000 Binary files a/screenshots/SI-B-04-00.jpg and /dev/null differ diff --git a/screenshots/SI-B-04-01.jpg b/screenshots/SI-B-04-01.jpg deleted file mode 100644 index 50eb8b8c..00000000 Binary files a/screenshots/SI-B-04-01.jpg and /dev/null differ diff --git a/screenshots/SI-B-04-02.jpg b/screenshots/SI-B-04-02.jpg deleted file mode 100644 index 8d3adff3..00000000 Binary files a/screenshots/SI-B-04-02.jpg and /dev/null differ diff --git a/screenshots/SI-B-04-03.jpg b/screenshots/SI-B-04-03.jpg deleted file mode 100644 index bc167eaf..00000000 Binary files a/screenshots/SI-B-04-03.jpg and /dev/null differ diff --git a/screenshots/SI-B-04-04.jpg b/screenshots/SI-B-04-04.jpg deleted file mode 100644 index d106462d..00000000 Binary files a/screenshots/SI-B-04-04.jpg and /dev/null differ diff --git a/screenshots/SI-B-04.jpg b/screenshots/SI-B-04.jpg deleted file mode 100644 index 31dc332a..00000000 Binary files a/screenshots/SI-B-04.jpg and /dev/null differ diff --git a/screenshots/SI-C-00-00.jpg b/screenshots/SI-C-00-00.jpg deleted file mode 100644 index 64bd9925..00000000 Binary files a/screenshots/SI-C-00-00.jpg and /dev/null differ diff --git a/screenshots/SI-C-00-01.jpg b/screenshots/SI-C-00-01.jpg deleted file mode 100644 index a8c1ab62..00000000 Binary files a/screenshots/SI-C-00-01.jpg and /dev/null differ diff --git a/screenshots/SI-C-00-02.jpg b/screenshots/SI-C-00-02.jpg deleted file mode 100644 index d4315f6d..00000000 Binary files a/screenshots/SI-C-00-02.jpg and /dev/null differ diff --git a/screenshots/SI-C-00-03.jpg b/screenshots/SI-C-00-03.jpg deleted file mode 100644 index faa1ade6..00000000 Binary files a/screenshots/SI-C-00-03.jpg and /dev/null differ diff --git a/screenshots/SI-C-00-04.jpg b/screenshots/SI-C-00-04.jpg deleted file mode 100644 index 5f36e5e8..00000000 Binary files a/screenshots/SI-C-00-04.jpg and /dev/null differ diff --git a/screenshots/SI-C-00.jpg b/screenshots/SI-C-00.jpg deleted file mode 100644 index 4748a1a7..00000000 Binary files a/screenshots/SI-C-00.jpg and /dev/null differ diff --git a/screenshots/SI-C-01-00.jpg b/screenshots/SI-C-01-00.jpg deleted file mode 100644 index 0e2b6b80..00000000 Binary files a/screenshots/SI-C-01-00.jpg and /dev/null differ diff --git a/screenshots/SI-C-01-01.jpg b/screenshots/SI-C-01-01.jpg deleted file mode 100644 index ee26f03e..00000000 Binary files a/screenshots/SI-C-01-01.jpg and /dev/null differ diff --git a/screenshots/SI-C-01-02.jpg b/screenshots/SI-C-01-02.jpg deleted file mode 100644 index a257095e..00000000 Binary files a/screenshots/SI-C-01-02.jpg and /dev/null differ diff --git a/screenshots/SI-C-01-03.jpg b/screenshots/SI-C-01-03.jpg deleted file mode 100644 index e5f9eebd..00000000 Binary files a/screenshots/SI-C-01-03.jpg and /dev/null differ diff --git a/screenshots/SI-C-01-04.jpg b/screenshots/SI-C-01-04.jpg deleted file mode 100644 index 9541a35f..00000000 Binary files a/screenshots/SI-C-01-04.jpg and /dev/null differ diff --git a/screenshots/SI-C-01.jpg b/screenshots/SI-C-01.jpg deleted file mode 100644 index e5d4fa81..00000000 Binary files a/screenshots/SI-C-01.jpg and /dev/null differ diff --git a/screenshots/SI-C-02-00.jpg b/screenshots/SI-C-02-00.jpg deleted file mode 100644 index 3cdc40da..00000000 Binary files a/screenshots/SI-C-02-00.jpg and /dev/null differ diff --git a/screenshots/SI-C-02-01.jpg b/screenshots/SI-C-02-01.jpg deleted file mode 100644 index 6730f935..00000000 Binary files a/screenshots/SI-C-02-01.jpg and /dev/null differ diff --git a/screenshots/SI-C-02-02.jpg b/screenshots/SI-C-02-02.jpg deleted file mode 100644 index 56b434cc..00000000 Binary files a/screenshots/SI-C-02-02.jpg and /dev/null differ diff --git a/screenshots/SI-C-02-03.jpg b/screenshots/SI-C-02-03.jpg deleted file mode 100644 index da84e088..00000000 Binary files a/screenshots/SI-C-02-03.jpg and /dev/null differ diff --git a/screenshots/SI-C-02-04.jpg b/screenshots/SI-C-02-04.jpg deleted file mode 100644 index aa22bdf2..00000000 Binary files a/screenshots/SI-C-02-04.jpg and /dev/null differ diff --git a/screenshots/SI-C-02.jpg b/screenshots/SI-C-02.jpg deleted file mode 100644 index 16a8dc06..00000000 Binary files a/screenshots/SI-C-02.jpg and /dev/null differ diff --git a/screenshots/SI-C-03-00.jpg b/screenshots/SI-C-03-00.jpg deleted file mode 100644 index deaf3602..00000000 Binary files a/screenshots/SI-C-03-00.jpg and /dev/null differ diff --git a/screenshots/SI-C-03-01.jpg b/screenshots/SI-C-03-01.jpg deleted file mode 100644 index 5b2db4c0..00000000 Binary files a/screenshots/SI-C-03-01.jpg and /dev/null differ diff --git a/screenshots/SI-C-03-02.jpg b/screenshots/SI-C-03-02.jpg deleted file mode 100644 index 5920e12b..00000000 Binary files a/screenshots/SI-C-03-02.jpg and /dev/null differ diff --git a/screenshots/SI-C-03-03.jpg b/screenshots/SI-C-03-03.jpg deleted file mode 100644 index 75e144b9..00000000 Binary files a/screenshots/SI-C-03-03.jpg and /dev/null differ diff --git a/screenshots/SI-C-03-04.jpg b/screenshots/SI-C-03-04.jpg deleted file mode 100644 index 7f88f70d..00000000 Binary files a/screenshots/SI-C-03-04.jpg and /dev/null differ diff --git a/screenshots/SI-C-03.jpg b/screenshots/SI-C-03.jpg deleted file mode 100644 index 85059d11..00000000 Binary files a/screenshots/SI-C-03.jpg and /dev/null differ diff --git a/screenshots/SI-C-04-00.jpg b/screenshots/SI-C-04-00.jpg deleted file mode 100644 index db95c1d0..00000000 Binary files a/screenshots/SI-C-04-00.jpg and /dev/null differ diff --git a/screenshots/SI-C-04-01.jpg b/screenshots/SI-C-04-01.jpg deleted file mode 100644 index 4c8fa5e8..00000000 Binary files a/screenshots/SI-C-04-01.jpg and /dev/null differ diff --git a/screenshots/SI-C-04-02.jpg b/screenshots/SI-C-04-02.jpg deleted file mode 100644 index f0f64abe..00000000 Binary files a/screenshots/SI-C-04-02.jpg and /dev/null differ diff --git a/screenshots/SI-C-04-03.jpg b/screenshots/SI-C-04-03.jpg deleted file mode 100644 index 4c165b25..00000000 Binary files a/screenshots/SI-C-04-03.jpg and /dev/null differ diff --git a/screenshots/SI-C-04-04.jpg b/screenshots/SI-C-04-04.jpg deleted file mode 100644 index 6c57ffcc..00000000 Binary files a/screenshots/SI-C-04-04.jpg and /dev/null differ diff --git a/screenshots/SI-C-04.jpg b/screenshots/SI-C-04.jpg deleted file mode 100644 index d4fd4d87..00000000 Binary files a/screenshots/SI-C-04.jpg and /dev/null differ diff --git a/screenshots/SI-D-00-00.jpg b/screenshots/SI-D-00-00.jpg deleted file mode 100644 index 6c3fc20e..00000000 Binary files a/screenshots/SI-D-00-00.jpg and /dev/null differ diff --git a/screenshots/SI-D-00-01.jpg b/screenshots/SI-D-00-01.jpg deleted file mode 100644 index dc91dcdc..00000000 Binary files a/screenshots/SI-D-00-01.jpg and /dev/null differ diff --git a/screenshots/SI-D-00-02.jpg b/screenshots/SI-D-00-02.jpg deleted file mode 100644 index 23da81a6..00000000 Binary files a/screenshots/SI-D-00-02.jpg and /dev/null differ diff --git a/screenshots/SI-D-00-03.jpg b/screenshots/SI-D-00-03.jpg deleted file mode 100644 index 50c39043..00000000 Binary files a/screenshots/SI-D-00-03.jpg and /dev/null differ diff --git a/screenshots/SI-D-00-04.jpg b/screenshots/SI-D-00-04.jpg deleted file mode 100644 index 341f7c65..00000000 Binary files a/screenshots/SI-D-00-04.jpg and /dev/null differ diff --git a/screenshots/SI-D-00.jpg b/screenshots/SI-D-00.jpg deleted file mode 100644 index 5e215ed7..00000000 Binary files a/screenshots/SI-D-00.jpg and /dev/null differ diff --git a/screenshots/SI-D-01-00.jpg b/screenshots/SI-D-01-00.jpg deleted file mode 100644 index 30cc3483..00000000 Binary files a/screenshots/SI-D-01-00.jpg and /dev/null differ diff --git a/screenshots/SI-D-01-01.jpg b/screenshots/SI-D-01-01.jpg deleted file mode 100644 index d7b9dbfd..00000000 Binary files a/screenshots/SI-D-01-01.jpg and /dev/null differ diff --git a/screenshots/SI-D-01-02.jpg b/screenshots/SI-D-01-02.jpg deleted file mode 100644 index eccb9d92..00000000 Binary files a/screenshots/SI-D-01-02.jpg and /dev/null differ diff --git a/screenshots/SI-D-01-03.jpg b/screenshots/SI-D-01-03.jpg deleted file mode 100644 index 3f2023c8..00000000 Binary files a/screenshots/SI-D-01-03.jpg and /dev/null differ diff --git a/screenshots/SI-D-01-04.jpg b/screenshots/SI-D-01-04.jpg deleted file mode 100644 index 0a79cd7a..00000000 Binary files a/screenshots/SI-D-01-04.jpg and /dev/null differ diff --git a/screenshots/SI-D-01.jpg b/screenshots/SI-D-01.jpg deleted file mode 100644 index 1755ea1e..00000000 Binary files a/screenshots/SI-D-01.jpg and /dev/null differ diff --git a/screenshots/SI-D-02-00.jpg b/screenshots/SI-D-02-00.jpg deleted file mode 100644 index 1861c1bb..00000000 Binary files a/screenshots/SI-D-02-00.jpg and /dev/null differ diff --git a/screenshots/SI-D-02-01.jpg b/screenshots/SI-D-02-01.jpg deleted file mode 100644 index a7512303..00000000 Binary files a/screenshots/SI-D-02-01.jpg and /dev/null differ diff --git a/screenshots/SI-D-02-02.jpg b/screenshots/SI-D-02-02.jpg deleted file mode 100644 index 3ffd5d6c..00000000 Binary files a/screenshots/SI-D-02-02.jpg and /dev/null differ diff --git a/screenshots/SI-D-02-03.jpg b/screenshots/SI-D-02-03.jpg deleted file mode 100644 index 99b76406..00000000 Binary files a/screenshots/SI-D-02-03.jpg and /dev/null differ diff --git a/screenshots/SI-D-02-04.jpg b/screenshots/SI-D-02-04.jpg deleted file mode 100644 index 935533f6..00000000 Binary files a/screenshots/SI-D-02-04.jpg and /dev/null differ diff --git a/screenshots/SI-D-02.jpg b/screenshots/SI-D-02.jpg deleted file mode 100644 index d5925d92..00000000 Binary files a/screenshots/SI-D-02.jpg and /dev/null differ diff --git a/screenshots/SI-D-03-00.jpg b/screenshots/SI-D-03-00.jpg deleted file mode 100644 index 4dbcacdf..00000000 Binary files a/screenshots/SI-D-03-00.jpg and /dev/null differ diff --git a/screenshots/SI-D-03-01.jpg b/screenshots/SI-D-03-01.jpg deleted file mode 100644 index 33e45b49..00000000 Binary files a/screenshots/SI-D-03-01.jpg and /dev/null differ diff --git a/screenshots/SI-D-03-02.jpg b/screenshots/SI-D-03-02.jpg deleted file mode 100644 index 90a927a0..00000000 Binary files a/screenshots/SI-D-03-02.jpg and /dev/null differ diff --git a/screenshots/SI-D-03-03.jpg b/screenshots/SI-D-03-03.jpg deleted file mode 100644 index 3fbd5edb..00000000 Binary files a/screenshots/SI-D-03-03.jpg and /dev/null differ diff --git a/screenshots/SI-D-03-04.jpg b/screenshots/SI-D-03-04.jpg deleted file mode 100644 index b740cecd..00000000 Binary files a/screenshots/SI-D-03-04.jpg and /dev/null differ diff --git a/screenshots/SI-D-03.jpg b/screenshots/SI-D-03.jpg deleted file mode 100644 index f0ca5b9a..00000000 Binary files a/screenshots/SI-D-03.jpg and /dev/null differ diff --git a/screenshots/SI-D-04-00.jpg b/screenshots/SI-D-04-00.jpg deleted file mode 100644 index 6aaf1488..00000000 Binary files a/screenshots/SI-D-04-00.jpg and /dev/null differ diff --git a/screenshots/SI-D-04-01.jpg b/screenshots/SI-D-04-01.jpg deleted file mode 100644 index 7c38b453..00000000 Binary files a/screenshots/SI-D-04-01.jpg and /dev/null differ diff --git a/screenshots/SI-D-04-02.jpg b/screenshots/SI-D-04-02.jpg deleted file mode 100644 index 690f9698..00000000 Binary files a/screenshots/SI-D-04-02.jpg and /dev/null differ diff --git a/screenshots/SI-D-04-03.jpg b/screenshots/SI-D-04-03.jpg deleted file mode 100644 index 30b80a0a..00000000 Binary files a/screenshots/SI-D-04-03.jpg and /dev/null differ diff --git a/screenshots/SI-D-04-04.jpg b/screenshots/SI-D-04-04.jpg deleted file mode 100644 index 7c0753d6..00000000 Binary files a/screenshots/SI-D-04-04.jpg and /dev/null differ diff --git a/screenshots/SI-D-04.jpg b/screenshots/SI-D-04.jpg deleted file mode 100644 index 3d19fd04..00000000 Binary files a/screenshots/SI-D-04.jpg and /dev/null differ diff --git a/screenshots/SI-E-00-00.jpg b/screenshots/SI-E-00-00.jpg deleted file mode 100644 index d02c0f9a..00000000 Binary files a/screenshots/SI-E-00-00.jpg and /dev/null differ diff --git a/screenshots/SI-E-00-01.jpg b/screenshots/SI-E-00-01.jpg deleted file mode 100644 index 2cace078..00000000 Binary files a/screenshots/SI-E-00-01.jpg and /dev/null differ diff --git a/screenshots/SI-E-00-02.jpg b/screenshots/SI-E-00-02.jpg deleted file mode 100644 index 2a70c885..00000000 Binary files a/screenshots/SI-E-00-02.jpg and /dev/null differ diff --git a/screenshots/SI-E-00-03.jpg b/screenshots/SI-E-00-03.jpg deleted file mode 100644 index f317b542..00000000 Binary files a/screenshots/SI-E-00-03.jpg and /dev/null differ diff --git a/screenshots/SI-E-00-04.jpg b/screenshots/SI-E-00-04.jpg deleted file mode 100644 index 87601ade..00000000 Binary files a/screenshots/SI-E-00-04.jpg and /dev/null differ diff --git a/screenshots/SI-E-00.jpg b/screenshots/SI-E-00.jpg deleted file mode 100644 index 905e7847..00000000 Binary files a/screenshots/SI-E-00.jpg and /dev/null differ diff --git a/screenshots/SI-E-01-00.jpg b/screenshots/SI-E-01-00.jpg deleted file mode 100644 index e73fd88a..00000000 Binary files a/screenshots/SI-E-01-00.jpg and /dev/null differ diff --git a/screenshots/SI-E-01-01.jpg b/screenshots/SI-E-01-01.jpg deleted file mode 100644 index b4ca02e6..00000000 Binary files a/screenshots/SI-E-01-01.jpg and /dev/null differ diff --git a/screenshots/SI-E-01-02.jpg b/screenshots/SI-E-01-02.jpg deleted file mode 100644 index 23de8459..00000000 Binary files a/screenshots/SI-E-01-02.jpg and /dev/null differ diff --git a/screenshots/SI-E-01-03.jpg b/screenshots/SI-E-01-03.jpg deleted file mode 100644 index 2c3dee1b..00000000 Binary files a/screenshots/SI-E-01-03.jpg and /dev/null differ diff --git a/screenshots/SI-E-01-04.jpg b/screenshots/SI-E-01-04.jpg deleted file mode 100644 index 7cfbaa88..00000000 Binary files a/screenshots/SI-E-01-04.jpg and /dev/null differ diff --git a/screenshots/SI-E-01.jpg b/screenshots/SI-E-01.jpg deleted file mode 100644 index 5d0ef238..00000000 Binary files a/screenshots/SI-E-01.jpg and /dev/null differ diff --git a/screenshots/SI-E-02-00.jpg b/screenshots/SI-E-02-00.jpg deleted file mode 100644 index 5ff2be27..00000000 Binary files a/screenshots/SI-E-02-00.jpg and /dev/null differ diff --git a/screenshots/SI-E-02-01.jpg b/screenshots/SI-E-02-01.jpg deleted file mode 100644 index 54daaf74..00000000 Binary files a/screenshots/SI-E-02-01.jpg and /dev/null differ diff --git a/screenshots/SI-E-02-02.jpg b/screenshots/SI-E-02-02.jpg deleted file mode 100644 index 269664a6..00000000 Binary files a/screenshots/SI-E-02-02.jpg and /dev/null differ diff --git a/screenshots/SI-E-02-03.jpg b/screenshots/SI-E-02-03.jpg deleted file mode 100644 index 70101529..00000000 Binary files a/screenshots/SI-E-02-03.jpg and /dev/null differ diff --git a/screenshots/SI-E-02-04.jpg b/screenshots/SI-E-02-04.jpg deleted file mode 100644 index 847d47b8..00000000 Binary files a/screenshots/SI-E-02-04.jpg and /dev/null differ diff --git a/screenshots/SI-E-02.jpg b/screenshots/SI-E-02.jpg deleted file mode 100644 index 4debf990..00000000 Binary files a/screenshots/SI-E-02.jpg and /dev/null differ diff --git a/screenshots/SI-E-03-00.jpg b/screenshots/SI-E-03-00.jpg deleted file mode 100644 index 824a1adb..00000000 Binary files a/screenshots/SI-E-03-00.jpg and /dev/null differ diff --git a/screenshots/SI-E-03-01.jpg b/screenshots/SI-E-03-01.jpg deleted file mode 100644 index d63f5d72..00000000 Binary files a/screenshots/SI-E-03-01.jpg and /dev/null differ diff --git a/screenshots/SI-E-03-02.jpg b/screenshots/SI-E-03-02.jpg deleted file mode 100644 index 2d3b7b7c..00000000 Binary files a/screenshots/SI-E-03-02.jpg and /dev/null differ diff --git a/screenshots/SI-E-03-03.jpg b/screenshots/SI-E-03-03.jpg deleted file mode 100644 index 1c347309..00000000 Binary files a/screenshots/SI-E-03-03.jpg and /dev/null differ diff --git a/screenshots/SI-E-03-04.jpg b/screenshots/SI-E-03-04.jpg deleted file mode 100644 index d5cc175b..00000000 Binary files a/screenshots/SI-E-03-04.jpg and /dev/null differ diff --git a/screenshots/SI-E-03.jpg b/screenshots/SI-E-03.jpg deleted file mode 100644 index 85b72670..00000000 Binary files a/screenshots/SI-E-03.jpg and /dev/null differ diff --git a/screenshots/SI-E-04-00.jpg b/screenshots/SI-E-04-00.jpg deleted file mode 100644 index 2fcb7976..00000000 Binary files a/screenshots/SI-E-04-00.jpg and /dev/null differ diff --git a/screenshots/SI-E-04-01.jpg b/screenshots/SI-E-04-01.jpg deleted file mode 100644 index dd77512a..00000000 Binary files a/screenshots/SI-E-04-01.jpg and /dev/null differ diff --git a/screenshots/SI-E-04-02.jpg b/screenshots/SI-E-04-02.jpg deleted file mode 100644 index 8e2f09b7..00000000 Binary files a/screenshots/SI-E-04-02.jpg and /dev/null differ diff --git a/screenshots/SI-E-04-03.jpg b/screenshots/SI-E-04-03.jpg deleted file mode 100644 index a52f7832..00000000 Binary files a/screenshots/SI-E-04-03.jpg and /dev/null differ diff --git a/screenshots/SI-E-04-04.jpg b/screenshots/SI-E-04-04.jpg deleted file mode 100644 index 616dfd91..00000000 Binary files a/screenshots/SI-E-04-04.jpg and /dev/null differ diff --git a/screenshots/SI-E-04.jpg b/screenshots/SI-E-04.jpg deleted file mode 100644 index 69030295..00000000 Binary files a/screenshots/SI-E-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-00-00.jpg b/screenshots/SL-A-00-00.jpg deleted file mode 100644 index c157b46d..00000000 Binary files a/screenshots/SL-A-00-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-00-01.jpg b/screenshots/SL-A-00-01.jpg deleted file mode 100644 index e3d30d53..00000000 Binary files a/screenshots/SL-A-00-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-00-02.jpg b/screenshots/SL-A-00-02.jpg deleted file mode 100644 index 9edd817f..00000000 Binary files a/screenshots/SL-A-00-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-00-03.jpg b/screenshots/SL-A-00-03.jpg deleted file mode 100644 index 9af8dcee..00000000 Binary files a/screenshots/SL-A-00-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-00-04.jpg b/screenshots/SL-A-00-04.jpg deleted file mode 100644 index c1dd44b3..00000000 Binary files a/screenshots/SL-A-00-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-00.jpg b/screenshots/SL-A-00.jpg deleted file mode 100644 index f37110cf..00000000 Binary files a/screenshots/SL-A-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-01-00.jpg b/screenshots/SL-A-01-00.jpg deleted file mode 100644 index 5cb35473..00000000 Binary files a/screenshots/SL-A-01-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-01-01.jpg b/screenshots/SL-A-01-01.jpg deleted file mode 100644 index 7f9dc90e..00000000 Binary files a/screenshots/SL-A-01-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-01-02.jpg b/screenshots/SL-A-01-02.jpg deleted file mode 100644 index 5241e487..00000000 Binary files a/screenshots/SL-A-01-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-01-03.jpg b/screenshots/SL-A-01-03.jpg deleted file mode 100644 index 13971ce7..00000000 Binary files a/screenshots/SL-A-01-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-01-04.jpg b/screenshots/SL-A-01-04.jpg deleted file mode 100644 index 28d1bcb5..00000000 Binary files a/screenshots/SL-A-01-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-01.jpg b/screenshots/SL-A-01.jpg deleted file mode 100644 index b4125923..00000000 Binary files a/screenshots/SL-A-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-02-00.jpg b/screenshots/SL-A-02-00.jpg deleted file mode 100644 index fe02a294..00000000 Binary files a/screenshots/SL-A-02-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-02-01.jpg b/screenshots/SL-A-02-01.jpg deleted file mode 100644 index 859653f4..00000000 Binary files a/screenshots/SL-A-02-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-02-02.jpg b/screenshots/SL-A-02-02.jpg deleted file mode 100644 index e2a69da3..00000000 Binary files a/screenshots/SL-A-02-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-02-03.jpg b/screenshots/SL-A-02-03.jpg deleted file mode 100644 index 391d23c8..00000000 Binary files a/screenshots/SL-A-02-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-02-04.jpg b/screenshots/SL-A-02-04.jpg deleted file mode 100644 index ea963499..00000000 Binary files a/screenshots/SL-A-02-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-02.jpg b/screenshots/SL-A-02.jpg deleted file mode 100644 index d4df0a76..00000000 Binary files a/screenshots/SL-A-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-03-00.jpg b/screenshots/SL-A-03-00.jpg deleted file mode 100644 index 5253c47f..00000000 Binary files a/screenshots/SL-A-03-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-03-01.jpg b/screenshots/SL-A-03-01.jpg deleted file mode 100644 index 8bbf33fb..00000000 Binary files a/screenshots/SL-A-03-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-03-02.jpg b/screenshots/SL-A-03-02.jpg deleted file mode 100644 index 1f67301c..00000000 Binary files a/screenshots/SL-A-03-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-03-03.jpg b/screenshots/SL-A-03-03.jpg deleted file mode 100644 index d5b9a8ba..00000000 Binary files a/screenshots/SL-A-03-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-03-04.jpg b/screenshots/SL-A-03-04.jpg deleted file mode 100644 index 5bd7e042..00000000 Binary files a/screenshots/SL-A-03-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-03.jpg b/screenshots/SL-A-03.jpg deleted file mode 100644 index fa893b98..00000000 Binary files a/screenshots/SL-A-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-04-00.jpg b/screenshots/SL-A-04-00.jpg deleted file mode 100644 index 56743f22..00000000 Binary files a/screenshots/SL-A-04-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-04-01.jpg b/screenshots/SL-A-04-01.jpg deleted file mode 100644 index f84e0222..00000000 Binary files a/screenshots/SL-A-04-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-04-02.jpg b/screenshots/SL-A-04-02.jpg deleted file mode 100644 index 62e3fb91..00000000 Binary files a/screenshots/SL-A-04-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-04-03.jpg b/screenshots/SL-A-04-03.jpg deleted file mode 100644 index 66f3dd65..00000000 Binary files a/screenshots/SL-A-04-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-04-04.jpg b/screenshots/SL-A-04-04.jpg deleted file mode 100644 index 10a1f686..00000000 Binary files a/screenshots/SL-A-04-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-04.jpg b/screenshots/SL-A-04.jpg deleted file mode 100644 index 9c6efb34..00000000 Binary files a/screenshots/SL-A-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-05-00.jpg b/screenshots/SL-A-05-00.jpg deleted file mode 100644 index 0f1705f8..00000000 Binary files a/screenshots/SL-A-05-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-05-01.jpg b/screenshots/SL-A-05-01.jpg deleted file mode 100644 index bf366aac..00000000 Binary files a/screenshots/SL-A-05-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-05-02.jpg b/screenshots/SL-A-05-02.jpg deleted file mode 100644 index 7a315973..00000000 Binary files a/screenshots/SL-A-05-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-05-03.jpg b/screenshots/SL-A-05-03.jpg deleted file mode 100644 index 6792ef4f..00000000 Binary files a/screenshots/SL-A-05-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-05-04.jpg b/screenshots/SL-A-05-04.jpg deleted file mode 100644 index 56198e80..00000000 Binary files a/screenshots/SL-A-05-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-05.jpg b/screenshots/SL-A-05.jpg deleted file mode 100644 index 2ac73102..00000000 Binary files a/screenshots/SL-A-05.jpg and /dev/null differ diff --git a/screenshots/SL-A-06-00.jpg b/screenshots/SL-A-06-00.jpg deleted file mode 100644 index af29cca9..00000000 Binary files a/screenshots/SL-A-06-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-06-01.jpg b/screenshots/SL-A-06-01.jpg deleted file mode 100644 index 6a49b0e4..00000000 Binary files a/screenshots/SL-A-06-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-06-02.jpg b/screenshots/SL-A-06-02.jpg deleted file mode 100644 index ef6ec29d..00000000 Binary files a/screenshots/SL-A-06-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-06-03.jpg b/screenshots/SL-A-06-03.jpg deleted file mode 100644 index 0e040d45..00000000 Binary files a/screenshots/SL-A-06-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-06-04.jpg b/screenshots/SL-A-06-04.jpg deleted file mode 100644 index 4f9335b4..00000000 Binary files a/screenshots/SL-A-06-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-06.jpg b/screenshots/SL-A-06.jpg deleted file mode 100644 index f042778f..00000000 Binary files a/screenshots/SL-A-06.jpg and /dev/null differ diff --git a/screenshots/SL-A-07-00.jpg b/screenshots/SL-A-07-00.jpg deleted file mode 100644 index 1f436a91..00000000 Binary files a/screenshots/SL-A-07-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-07-01.jpg b/screenshots/SL-A-07-01.jpg deleted file mode 100644 index 08091241..00000000 Binary files a/screenshots/SL-A-07-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-07-02.jpg b/screenshots/SL-A-07-02.jpg deleted file mode 100644 index d2b4d078..00000000 Binary files a/screenshots/SL-A-07-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-07-03.jpg b/screenshots/SL-A-07-03.jpg deleted file mode 100644 index 04ab2435..00000000 Binary files a/screenshots/SL-A-07-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-07-04.jpg b/screenshots/SL-A-07-04.jpg deleted file mode 100644 index 25f3bc90..00000000 Binary files a/screenshots/SL-A-07-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-07.jpg b/screenshots/SL-A-07.jpg deleted file mode 100644 index 2f1fa0d4..00000000 Binary files a/screenshots/SL-A-07.jpg and /dev/null differ diff --git a/screenshots/SL-A-08-00.jpg b/screenshots/SL-A-08-00.jpg deleted file mode 100644 index eafefb62..00000000 Binary files a/screenshots/SL-A-08-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-08-01.jpg b/screenshots/SL-A-08-01.jpg deleted file mode 100644 index affbebab..00000000 Binary files a/screenshots/SL-A-08-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-08-02.jpg b/screenshots/SL-A-08-02.jpg deleted file mode 100644 index 911c506b..00000000 Binary files a/screenshots/SL-A-08-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-08-03.jpg b/screenshots/SL-A-08-03.jpg deleted file mode 100644 index ce1960f9..00000000 Binary files a/screenshots/SL-A-08-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-08-04.jpg b/screenshots/SL-A-08-04.jpg deleted file mode 100644 index 7c995d42..00000000 Binary files a/screenshots/SL-A-08-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-08.jpg b/screenshots/SL-A-08.jpg deleted file mode 100644 index a658f07c..00000000 Binary files a/screenshots/SL-A-08.jpg and /dev/null differ diff --git a/screenshots/SL-A-09-00.jpg b/screenshots/SL-A-09-00.jpg deleted file mode 100644 index db1264be..00000000 Binary files a/screenshots/SL-A-09-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-09-01.jpg b/screenshots/SL-A-09-01.jpg deleted file mode 100644 index 89f814a6..00000000 Binary files a/screenshots/SL-A-09-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-09-02.jpg b/screenshots/SL-A-09-02.jpg deleted file mode 100644 index fc8113b7..00000000 Binary files a/screenshots/SL-A-09-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-09-03.jpg b/screenshots/SL-A-09-03.jpg deleted file mode 100644 index aadef4fc..00000000 Binary files a/screenshots/SL-A-09-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-09-04.jpg b/screenshots/SL-A-09-04.jpg deleted file mode 100644 index 1db044d7..00000000 Binary files a/screenshots/SL-A-09-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-09.jpg b/screenshots/SL-A-09.jpg deleted file mode 100644 index 0f36f84f..00000000 Binary files a/screenshots/SL-A-09.jpg and /dev/null differ diff --git a/screenshots/SL-A-10-00.jpg b/screenshots/SL-A-10-00.jpg deleted file mode 100644 index daeb083e..00000000 Binary files a/screenshots/SL-A-10-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-10-01.jpg b/screenshots/SL-A-10-01.jpg deleted file mode 100644 index 00e4ce2a..00000000 Binary files a/screenshots/SL-A-10-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-10-02.jpg b/screenshots/SL-A-10-02.jpg deleted file mode 100644 index bc9f2bbd..00000000 Binary files a/screenshots/SL-A-10-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-10-03.jpg b/screenshots/SL-A-10-03.jpg deleted file mode 100644 index faceda61..00000000 Binary files a/screenshots/SL-A-10-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-10-04.jpg b/screenshots/SL-A-10-04.jpg deleted file mode 100644 index a20e761b..00000000 Binary files a/screenshots/SL-A-10-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-10.jpg b/screenshots/SL-A-10.jpg deleted file mode 100644 index 15a25689..00000000 Binary files a/screenshots/SL-A-10.jpg and /dev/null differ diff --git a/screenshots/SL-A-11-00.jpg b/screenshots/SL-A-11-00.jpg deleted file mode 100644 index 6ecda964..00000000 Binary files a/screenshots/SL-A-11-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-11-01.jpg b/screenshots/SL-A-11-01.jpg deleted file mode 100644 index 63f49349..00000000 Binary files a/screenshots/SL-A-11-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-11-02.jpg b/screenshots/SL-A-11-02.jpg deleted file mode 100644 index 2eeee643..00000000 Binary files a/screenshots/SL-A-11-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-11-03.jpg b/screenshots/SL-A-11-03.jpg deleted file mode 100644 index e15b16df..00000000 Binary files a/screenshots/SL-A-11-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-11-04.jpg b/screenshots/SL-A-11-04.jpg deleted file mode 100644 index e2cf82b1..00000000 Binary files a/screenshots/SL-A-11-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-11.jpg b/screenshots/SL-A-11.jpg deleted file mode 100644 index fe80494b..00000000 Binary files a/screenshots/SL-A-11.jpg and /dev/null differ diff --git a/screenshots/SL-A-12-00.jpg b/screenshots/SL-A-12-00.jpg deleted file mode 100644 index 63aab4ce..00000000 Binary files a/screenshots/SL-A-12-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-12-01.jpg b/screenshots/SL-A-12-01.jpg deleted file mode 100644 index 76f040a3..00000000 Binary files a/screenshots/SL-A-12-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-12-02.jpg b/screenshots/SL-A-12-02.jpg deleted file mode 100644 index db1b6dce..00000000 Binary files a/screenshots/SL-A-12-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-12-03.jpg b/screenshots/SL-A-12-03.jpg deleted file mode 100644 index 69c0c5a6..00000000 Binary files a/screenshots/SL-A-12-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-12-04.jpg b/screenshots/SL-A-12-04.jpg deleted file mode 100644 index b21b3274..00000000 Binary files a/screenshots/SL-A-12-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-12.jpg b/screenshots/SL-A-12.jpg deleted file mode 100644 index 2aeefa70..00000000 Binary files a/screenshots/SL-A-12.jpg and /dev/null differ diff --git a/screenshots/SL-A-13-00.jpg b/screenshots/SL-A-13-00.jpg deleted file mode 100644 index ee936b79..00000000 Binary files a/screenshots/SL-A-13-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-13-01.jpg b/screenshots/SL-A-13-01.jpg deleted file mode 100644 index 8952625b..00000000 Binary files a/screenshots/SL-A-13-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-13-02.jpg b/screenshots/SL-A-13-02.jpg deleted file mode 100644 index af2bef51..00000000 Binary files a/screenshots/SL-A-13-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-13-03.jpg b/screenshots/SL-A-13-03.jpg deleted file mode 100644 index 6e972a38..00000000 Binary files a/screenshots/SL-A-13-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-13-04.jpg b/screenshots/SL-A-13-04.jpg deleted file mode 100644 index 9717d5fc..00000000 Binary files a/screenshots/SL-A-13-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-13.jpg b/screenshots/SL-A-13.jpg deleted file mode 100644 index 4268ec8f..00000000 Binary files a/screenshots/SL-A-13.jpg and /dev/null differ diff --git a/screenshots/SL-A-14-00.jpg b/screenshots/SL-A-14-00.jpg deleted file mode 100644 index 54cfeb98..00000000 Binary files a/screenshots/SL-A-14-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-14-01.jpg b/screenshots/SL-A-14-01.jpg deleted file mode 100644 index 75d03639..00000000 Binary files a/screenshots/SL-A-14-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-14-02.jpg b/screenshots/SL-A-14-02.jpg deleted file mode 100644 index 1ae7ae03..00000000 Binary files a/screenshots/SL-A-14-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-14-03.jpg b/screenshots/SL-A-14-03.jpg deleted file mode 100644 index 0fb42ec7..00000000 Binary files a/screenshots/SL-A-14-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-14-04.jpg b/screenshots/SL-A-14-04.jpg deleted file mode 100644 index 7812de1e..00000000 Binary files a/screenshots/SL-A-14-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-14.jpg b/screenshots/SL-A-14.jpg deleted file mode 100644 index a84532c1..00000000 Binary files a/screenshots/SL-A-14.jpg and /dev/null differ diff --git a/screenshots/SL-A-15-00.jpg b/screenshots/SL-A-15-00.jpg deleted file mode 100644 index 1c3ea635..00000000 Binary files a/screenshots/SL-A-15-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-15-01.jpg b/screenshots/SL-A-15-01.jpg deleted file mode 100644 index 013864a6..00000000 Binary files a/screenshots/SL-A-15-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-15-02.jpg b/screenshots/SL-A-15-02.jpg deleted file mode 100644 index 3ea081aa..00000000 Binary files a/screenshots/SL-A-15-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-15-03.jpg b/screenshots/SL-A-15-03.jpg deleted file mode 100644 index 6214221e..00000000 Binary files a/screenshots/SL-A-15-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-15-04.jpg b/screenshots/SL-A-15-04.jpg deleted file mode 100644 index 376c69b9..00000000 Binary files a/screenshots/SL-A-15-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-15.jpg b/screenshots/SL-A-15.jpg deleted file mode 100644 index 529b61dd..00000000 Binary files a/screenshots/SL-A-15.jpg and /dev/null differ diff --git a/screenshots/SL-A-16-00.jpg b/screenshots/SL-A-16-00.jpg deleted file mode 100644 index 7360c764..00000000 Binary files a/screenshots/SL-A-16-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-16-01.jpg b/screenshots/SL-A-16-01.jpg deleted file mode 100644 index 62018dcf..00000000 Binary files a/screenshots/SL-A-16-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-16-02.jpg b/screenshots/SL-A-16-02.jpg deleted file mode 100644 index f458b942..00000000 Binary files a/screenshots/SL-A-16-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-16-03.jpg b/screenshots/SL-A-16-03.jpg deleted file mode 100644 index 23cf8baf..00000000 Binary files a/screenshots/SL-A-16-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-16-04.jpg b/screenshots/SL-A-16-04.jpg deleted file mode 100644 index 990ec994..00000000 Binary files a/screenshots/SL-A-16-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-16.jpg b/screenshots/SL-A-16.jpg deleted file mode 100644 index 906a44b3..00000000 Binary files a/screenshots/SL-A-16.jpg and /dev/null differ diff --git a/screenshots/SL-A-17-00.jpg b/screenshots/SL-A-17-00.jpg deleted file mode 100644 index 04c1301e..00000000 Binary files a/screenshots/SL-A-17-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-17-01.jpg b/screenshots/SL-A-17-01.jpg deleted file mode 100644 index 06f80a21..00000000 Binary files a/screenshots/SL-A-17-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-17-02.jpg b/screenshots/SL-A-17-02.jpg deleted file mode 100644 index 233a06c7..00000000 Binary files a/screenshots/SL-A-17-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-17-03.jpg b/screenshots/SL-A-17-03.jpg deleted file mode 100644 index e5e38272..00000000 Binary files a/screenshots/SL-A-17-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-17-04.jpg b/screenshots/SL-A-17-04.jpg deleted file mode 100644 index 75b257f8..00000000 Binary files a/screenshots/SL-A-17-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-17.jpg b/screenshots/SL-A-17.jpg deleted file mode 100644 index f04d4011..00000000 Binary files a/screenshots/SL-A-17.jpg and /dev/null differ diff --git a/screenshots/SL-A-18-00.jpg b/screenshots/SL-A-18-00.jpg deleted file mode 100644 index c05c9f2b..00000000 Binary files a/screenshots/SL-A-18-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-18-01.jpg b/screenshots/SL-A-18-01.jpg deleted file mode 100644 index d0b86878..00000000 Binary files a/screenshots/SL-A-18-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-18-02.jpg b/screenshots/SL-A-18-02.jpg deleted file mode 100644 index ef8c208d..00000000 Binary files a/screenshots/SL-A-18-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-18-03.jpg b/screenshots/SL-A-18-03.jpg deleted file mode 100644 index 95d8edc6..00000000 Binary files a/screenshots/SL-A-18-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-18-04.jpg b/screenshots/SL-A-18-04.jpg deleted file mode 100644 index 0b2af34a..00000000 Binary files a/screenshots/SL-A-18-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-18.jpg b/screenshots/SL-A-18.jpg deleted file mode 100644 index 8302ab8c..00000000 Binary files a/screenshots/SL-A-18.jpg and /dev/null differ diff --git a/screenshots/SL-A-19-00.jpg b/screenshots/SL-A-19-00.jpg deleted file mode 100644 index 5f205eb3..00000000 Binary files a/screenshots/SL-A-19-00.jpg and /dev/null differ diff --git a/screenshots/SL-A-19-01.jpg b/screenshots/SL-A-19-01.jpg deleted file mode 100644 index 440891d2..00000000 Binary files a/screenshots/SL-A-19-01.jpg and /dev/null differ diff --git a/screenshots/SL-A-19-02.jpg b/screenshots/SL-A-19-02.jpg deleted file mode 100644 index 22b4fa41..00000000 Binary files a/screenshots/SL-A-19-02.jpg and /dev/null differ diff --git a/screenshots/SL-A-19-03.jpg b/screenshots/SL-A-19-03.jpg deleted file mode 100644 index b080dccb..00000000 Binary files a/screenshots/SL-A-19-03.jpg and /dev/null differ diff --git a/screenshots/SL-A-19-04.jpg b/screenshots/SL-A-19-04.jpg deleted file mode 100644 index 0de287c9..00000000 Binary files a/screenshots/SL-A-19-04.jpg and /dev/null differ diff --git a/screenshots/SL-A-19.jpg b/screenshots/SL-A-19.jpg deleted file mode 100644 index 2e0bcf81..00000000 Binary files a/screenshots/SL-A-19.jpg and /dev/null differ diff --git a/screenshots/SL-B-00-00.jpg b/screenshots/SL-B-00-00.jpg deleted file mode 100644 index ffecd7b0..00000000 Binary files a/screenshots/SL-B-00-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-00-01.jpg b/screenshots/SL-B-00-01.jpg deleted file mode 100644 index e0dccc3d..00000000 Binary files a/screenshots/SL-B-00-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-00-02.jpg b/screenshots/SL-B-00-02.jpg deleted file mode 100644 index 99faa29b..00000000 Binary files a/screenshots/SL-B-00-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-00-03.jpg b/screenshots/SL-B-00-03.jpg deleted file mode 100644 index eed7325f..00000000 Binary files a/screenshots/SL-B-00-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-00-04.jpg b/screenshots/SL-B-00-04.jpg deleted file mode 100644 index 00d074f9..00000000 Binary files a/screenshots/SL-B-00-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-00.jpg b/screenshots/SL-B-00.jpg deleted file mode 100644 index 4b7f1978..00000000 Binary files a/screenshots/SL-B-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-01-00.jpg b/screenshots/SL-B-01-00.jpg deleted file mode 100644 index 903b7bce..00000000 Binary files a/screenshots/SL-B-01-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-01-01.jpg b/screenshots/SL-B-01-01.jpg deleted file mode 100644 index 2201e604..00000000 Binary files a/screenshots/SL-B-01-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-01-02.jpg b/screenshots/SL-B-01-02.jpg deleted file mode 100644 index 547e7221..00000000 Binary files a/screenshots/SL-B-01-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-01-03.jpg b/screenshots/SL-B-01-03.jpg deleted file mode 100644 index 98318959..00000000 Binary files a/screenshots/SL-B-01-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-01-04.jpg b/screenshots/SL-B-01-04.jpg deleted file mode 100644 index 075c2801..00000000 Binary files a/screenshots/SL-B-01-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-01.jpg b/screenshots/SL-B-01.jpg deleted file mode 100644 index bf74b215..00000000 Binary files a/screenshots/SL-B-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-02-00.jpg b/screenshots/SL-B-02-00.jpg deleted file mode 100644 index 3ffeb285..00000000 Binary files a/screenshots/SL-B-02-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-02-01.jpg b/screenshots/SL-B-02-01.jpg deleted file mode 100644 index 470b582c..00000000 Binary files a/screenshots/SL-B-02-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-02-02.jpg b/screenshots/SL-B-02-02.jpg deleted file mode 100644 index cfd2e4ff..00000000 Binary files a/screenshots/SL-B-02-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-02-03.jpg b/screenshots/SL-B-02-03.jpg deleted file mode 100644 index cf7fa074..00000000 Binary files a/screenshots/SL-B-02-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-02-04.jpg b/screenshots/SL-B-02-04.jpg deleted file mode 100644 index c7a32f31..00000000 Binary files a/screenshots/SL-B-02-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-02.jpg b/screenshots/SL-B-02.jpg deleted file mode 100644 index 0977b453..00000000 Binary files a/screenshots/SL-B-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-03-00.jpg b/screenshots/SL-B-03-00.jpg deleted file mode 100644 index 7404a279..00000000 Binary files a/screenshots/SL-B-03-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-03-01.jpg b/screenshots/SL-B-03-01.jpg deleted file mode 100644 index 3311aa63..00000000 Binary files a/screenshots/SL-B-03-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-03-02.jpg b/screenshots/SL-B-03-02.jpg deleted file mode 100644 index cdf3ce0a..00000000 Binary files a/screenshots/SL-B-03-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-03-03.jpg b/screenshots/SL-B-03-03.jpg deleted file mode 100644 index 51f4d53b..00000000 Binary files a/screenshots/SL-B-03-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-03-04.jpg b/screenshots/SL-B-03-04.jpg deleted file mode 100644 index 6df412e3..00000000 Binary files a/screenshots/SL-B-03-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-03.jpg b/screenshots/SL-B-03.jpg deleted file mode 100644 index db3ac2b3..00000000 Binary files a/screenshots/SL-B-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-04-00.jpg b/screenshots/SL-B-04-00.jpg deleted file mode 100644 index b5d48f69..00000000 Binary files a/screenshots/SL-B-04-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-04-01.jpg b/screenshots/SL-B-04-01.jpg deleted file mode 100644 index c26c3f35..00000000 Binary files a/screenshots/SL-B-04-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-04-02.jpg b/screenshots/SL-B-04-02.jpg deleted file mode 100644 index a62a3814..00000000 Binary files a/screenshots/SL-B-04-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-04-03.jpg b/screenshots/SL-B-04-03.jpg deleted file mode 100644 index f5330247..00000000 Binary files a/screenshots/SL-B-04-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-04-04.jpg b/screenshots/SL-B-04-04.jpg deleted file mode 100644 index 032ea2d0..00000000 Binary files a/screenshots/SL-B-04-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-04.jpg b/screenshots/SL-B-04.jpg deleted file mode 100644 index 02b90c81..00000000 Binary files a/screenshots/SL-B-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-05-00.jpg b/screenshots/SL-B-05-00.jpg deleted file mode 100644 index 1a85e42c..00000000 Binary files a/screenshots/SL-B-05-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-05-01.jpg b/screenshots/SL-B-05-01.jpg deleted file mode 100644 index 7b60d5a7..00000000 Binary files a/screenshots/SL-B-05-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-05-02.jpg b/screenshots/SL-B-05-02.jpg deleted file mode 100644 index 8d6ccd15..00000000 Binary files a/screenshots/SL-B-05-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-05-03.jpg b/screenshots/SL-B-05-03.jpg deleted file mode 100644 index d6468ca2..00000000 Binary files a/screenshots/SL-B-05-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-05-04.jpg b/screenshots/SL-B-05-04.jpg deleted file mode 100644 index f565a3c8..00000000 Binary files a/screenshots/SL-B-05-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-05.jpg b/screenshots/SL-B-05.jpg deleted file mode 100644 index bc9895bc..00000000 Binary files a/screenshots/SL-B-05.jpg and /dev/null differ diff --git a/screenshots/SL-B-06-00.jpg b/screenshots/SL-B-06-00.jpg deleted file mode 100644 index ea960835..00000000 Binary files a/screenshots/SL-B-06-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-06-01.jpg b/screenshots/SL-B-06-01.jpg deleted file mode 100644 index fd097f36..00000000 Binary files a/screenshots/SL-B-06-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-06-02.jpg b/screenshots/SL-B-06-02.jpg deleted file mode 100644 index 588c3f0d..00000000 Binary files a/screenshots/SL-B-06-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-06-03.jpg b/screenshots/SL-B-06-03.jpg deleted file mode 100644 index 675e72db..00000000 Binary files a/screenshots/SL-B-06-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-06-04.jpg b/screenshots/SL-B-06-04.jpg deleted file mode 100644 index 9a192d41..00000000 Binary files a/screenshots/SL-B-06-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-06.jpg b/screenshots/SL-B-06.jpg deleted file mode 100644 index 67dfc75a..00000000 Binary files a/screenshots/SL-B-06.jpg and /dev/null differ diff --git a/screenshots/SL-B-07-00.jpg b/screenshots/SL-B-07-00.jpg deleted file mode 100644 index fc82f5b4..00000000 Binary files a/screenshots/SL-B-07-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-07-01.jpg b/screenshots/SL-B-07-01.jpg deleted file mode 100644 index 28164cd9..00000000 Binary files a/screenshots/SL-B-07-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-07-02.jpg b/screenshots/SL-B-07-02.jpg deleted file mode 100644 index 36faf397..00000000 Binary files a/screenshots/SL-B-07-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-07-03.jpg b/screenshots/SL-B-07-03.jpg deleted file mode 100644 index 7d3ccca2..00000000 Binary files a/screenshots/SL-B-07-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-07-04.jpg b/screenshots/SL-B-07-04.jpg deleted file mode 100644 index a2ea3d19..00000000 Binary files a/screenshots/SL-B-07-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-07.jpg b/screenshots/SL-B-07.jpg deleted file mode 100644 index 62f16117..00000000 Binary files a/screenshots/SL-B-07.jpg and /dev/null differ diff --git a/screenshots/SL-B-08-00.jpg b/screenshots/SL-B-08-00.jpg deleted file mode 100644 index 6562e4d9..00000000 Binary files a/screenshots/SL-B-08-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-08-01.jpg b/screenshots/SL-B-08-01.jpg deleted file mode 100644 index c3cab2f8..00000000 Binary files a/screenshots/SL-B-08-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-08-02.jpg b/screenshots/SL-B-08-02.jpg deleted file mode 100644 index 10bbe9d1..00000000 Binary files a/screenshots/SL-B-08-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-08-03.jpg b/screenshots/SL-B-08-03.jpg deleted file mode 100644 index c535954f..00000000 Binary files a/screenshots/SL-B-08-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-08-04.jpg b/screenshots/SL-B-08-04.jpg deleted file mode 100644 index 900a30a8..00000000 Binary files a/screenshots/SL-B-08-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-08.jpg b/screenshots/SL-B-08.jpg deleted file mode 100644 index f527f2c2..00000000 Binary files a/screenshots/SL-B-08.jpg and /dev/null differ diff --git a/screenshots/SL-B-09-00.jpg b/screenshots/SL-B-09-00.jpg deleted file mode 100644 index a613ab28..00000000 Binary files a/screenshots/SL-B-09-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-09-01.jpg b/screenshots/SL-B-09-01.jpg deleted file mode 100644 index 773c32c2..00000000 Binary files a/screenshots/SL-B-09-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-09-02.jpg b/screenshots/SL-B-09-02.jpg deleted file mode 100644 index b5ce7468..00000000 Binary files a/screenshots/SL-B-09-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-09-03.jpg b/screenshots/SL-B-09-03.jpg deleted file mode 100644 index 5719b8c6..00000000 Binary files a/screenshots/SL-B-09-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-09-04.jpg b/screenshots/SL-B-09-04.jpg deleted file mode 100644 index 01953043..00000000 Binary files a/screenshots/SL-B-09-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-09.jpg b/screenshots/SL-B-09.jpg deleted file mode 100644 index ba0b2cea..00000000 Binary files a/screenshots/SL-B-09.jpg and /dev/null differ diff --git a/screenshots/SL-B-10-00.jpg b/screenshots/SL-B-10-00.jpg deleted file mode 100644 index 513b5c86..00000000 Binary files a/screenshots/SL-B-10-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-10-01.jpg b/screenshots/SL-B-10-01.jpg deleted file mode 100644 index 9e05799e..00000000 Binary files a/screenshots/SL-B-10-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-10-02.jpg b/screenshots/SL-B-10-02.jpg deleted file mode 100644 index 42e2e55c..00000000 Binary files a/screenshots/SL-B-10-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-10-03.jpg b/screenshots/SL-B-10-03.jpg deleted file mode 100644 index 1d373550..00000000 Binary files a/screenshots/SL-B-10-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-10-04.jpg b/screenshots/SL-B-10-04.jpg deleted file mode 100644 index 80a9f642..00000000 Binary files a/screenshots/SL-B-10-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-10.jpg b/screenshots/SL-B-10.jpg deleted file mode 100644 index d05e37a3..00000000 Binary files a/screenshots/SL-B-10.jpg and /dev/null differ diff --git a/screenshots/SL-B-11-00.jpg b/screenshots/SL-B-11-00.jpg deleted file mode 100644 index 6472b496..00000000 Binary files a/screenshots/SL-B-11-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-11-01.jpg b/screenshots/SL-B-11-01.jpg deleted file mode 100644 index dec8c0e4..00000000 Binary files a/screenshots/SL-B-11-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-11-02.jpg b/screenshots/SL-B-11-02.jpg deleted file mode 100644 index 2cb6aeec..00000000 Binary files a/screenshots/SL-B-11-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-11-03.jpg b/screenshots/SL-B-11-03.jpg deleted file mode 100644 index ef921df7..00000000 Binary files a/screenshots/SL-B-11-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-11-04.jpg b/screenshots/SL-B-11-04.jpg deleted file mode 100644 index 14d106b6..00000000 Binary files a/screenshots/SL-B-11-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-11.jpg b/screenshots/SL-B-11.jpg deleted file mode 100644 index 443b3048..00000000 Binary files a/screenshots/SL-B-11.jpg and /dev/null differ diff --git a/screenshots/SL-B-12-00.jpg b/screenshots/SL-B-12-00.jpg deleted file mode 100644 index 7c1decd8..00000000 Binary files a/screenshots/SL-B-12-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-12-01.jpg b/screenshots/SL-B-12-01.jpg deleted file mode 100644 index cb6ac2fa..00000000 Binary files a/screenshots/SL-B-12-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-12-02.jpg b/screenshots/SL-B-12-02.jpg deleted file mode 100644 index 55e8bff1..00000000 Binary files a/screenshots/SL-B-12-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-12-03.jpg b/screenshots/SL-B-12-03.jpg deleted file mode 100644 index fd95e052..00000000 Binary files a/screenshots/SL-B-12-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-12-04.jpg b/screenshots/SL-B-12-04.jpg deleted file mode 100644 index 82133645..00000000 Binary files a/screenshots/SL-B-12-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-12.jpg b/screenshots/SL-B-12.jpg deleted file mode 100644 index 82b048c8..00000000 Binary files a/screenshots/SL-B-12.jpg and /dev/null differ diff --git a/screenshots/SL-B-13-00.jpg b/screenshots/SL-B-13-00.jpg deleted file mode 100644 index 155b393e..00000000 Binary files a/screenshots/SL-B-13-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-13-01.jpg b/screenshots/SL-B-13-01.jpg deleted file mode 100644 index adf48865..00000000 Binary files a/screenshots/SL-B-13-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-13-02.jpg b/screenshots/SL-B-13-02.jpg deleted file mode 100644 index 9921e920..00000000 Binary files a/screenshots/SL-B-13-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-13-03.jpg b/screenshots/SL-B-13-03.jpg deleted file mode 100644 index 0bff7b62..00000000 Binary files a/screenshots/SL-B-13-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-13-04.jpg b/screenshots/SL-B-13-04.jpg deleted file mode 100644 index 5455e02e..00000000 Binary files a/screenshots/SL-B-13-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-13.jpg b/screenshots/SL-B-13.jpg deleted file mode 100644 index 13b1603b..00000000 Binary files a/screenshots/SL-B-13.jpg and /dev/null differ diff --git a/screenshots/SL-B-14-00.jpg b/screenshots/SL-B-14-00.jpg deleted file mode 100644 index 4d67f968..00000000 Binary files a/screenshots/SL-B-14-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-14-01.jpg b/screenshots/SL-B-14-01.jpg deleted file mode 100644 index 93a42a0a..00000000 Binary files a/screenshots/SL-B-14-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-14-02.jpg b/screenshots/SL-B-14-02.jpg deleted file mode 100644 index 9b5b84d9..00000000 Binary files a/screenshots/SL-B-14-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-14-03.jpg b/screenshots/SL-B-14-03.jpg deleted file mode 100644 index 4fa9e7bd..00000000 Binary files a/screenshots/SL-B-14-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-14-04.jpg b/screenshots/SL-B-14-04.jpg deleted file mode 100644 index 0e8490e1..00000000 Binary files a/screenshots/SL-B-14-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-14.jpg b/screenshots/SL-B-14.jpg deleted file mode 100644 index 721c198a..00000000 Binary files a/screenshots/SL-B-14.jpg and /dev/null differ diff --git a/screenshots/SL-B-15-00.jpg b/screenshots/SL-B-15-00.jpg deleted file mode 100644 index 1840e85a..00000000 Binary files a/screenshots/SL-B-15-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-15-01.jpg b/screenshots/SL-B-15-01.jpg deleted file mode 100644 index 3b1bcc1d..00000000 Binary files a/screenshots/SL-B-15-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-15-02.jpg b/screenshots/SL-B-15-02.jpg deleted file mode 100644 index 3799de63..00000000 Binary files a/screenshots/SL-B-15-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-15-03.jpg b/screenshots/SL-B-15-03.jpg deleted file mode 100644 index 284a3aac..00000000 Binary files a/screenshots/SL-B-15-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-15-04.jpg b/screenshots/SL-B-15-04.jpg deleted file mode 100644 index 14b3cc6e..00000000 Binary files a/screenshots/SL-B-15-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-15.jpg b/screenshots/SL-B-15.jpg deleted file mode 100644 index 1e9305cf..00000000 Binary files a/screenshots/SL-B-15.jpg and /dev/null differ diff --git a/screenshots/SL-B-16-00.jpg b/screenshots/SL-B-16-00.jpg deleted file mode 100644 index 03d25c04..00000000 Binary files a/screenshots/SL-B-16-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-16-01.jpg b/screenshots/SL-B-16-01.jpg deleted file mode 100644 index c08388dc..00000000 Binary files a/screenshots/SL-B-16-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-16-02.jpg b/screenshots/SL-B-16-02.jpg deleted file mode 100644 index 8ae3212f..00000000 Binary files a/screenshots/SL-B-16-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-16-03.jpg b/screenshots/SL-B-16-03.jpg deleted file mode 100644 index 4a5be861..00000000 Binary files a/screenshots/SL-B-16-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-16-04.jpg b/screenshots/SL-B-16-04.jpg deleted file mode 100644 index d0d734f7..00000000 Binary files a/screenshots/SL-B-16-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-16.jpg b/screenshots/SL-B-16.jpg deleted file mode 100644 index 5b9f5c41..00000000 Binary files a/screenshots/SL-B-16.jpg and /dev/null differ diff --git a/screenshots/SL-B-17-00.jpg b/screenshots/SL-B-17-00.jpg deleted file mode 100644 index 4895c931..00000000 Binary files a/screenshots/SL-B-17-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-17-01.jpg b/screenshots/SL-B-17-01.jpg deleted file mode 100644 index 1c78d4eb..00000000 Binary files a/screenshots/SL-B-17-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-17-02.jpg b/screenshots/SL-B-17-02.jpg deleted file mode 100644 index 94a79549..00000000 Binary files a/screenshots/SL-B-17-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-17-03.jpg b/screenshots/SL-B-17-03.jpg deleted file mode 100644 index 9c141148..00000000 Binary files a/screenshots/SL-B-17-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-17-04.jpg b/screenshots/SL-B-17-04.jpg deleted file mode 100644 index eb5d5c0b..00000000 Binary files a/screenshots/SL-B-17-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-17.jpg b/screenshots/SL-B-17.jpg deleted file mode 100644 index dd74f5e6..00000000 Binary files a/screenshots/SL-B-17.jpg and /dev/null differ diff --git a/screenshots/SL-B-18-00.jpg b/screenshots/SL-B-18-00.jpg deleted file mode 100644 index 1afb782b..00000000 Binary files a/screenshots/SL-B-18-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-18-01.jpg b/screenshots/SL-B-18-01.jpg deleted file mode 100644 index 19119f07..00000000 Binary files a/screenshots/SL-B-18-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-18-02.jpg b/screenshots/SL-B-18-02.jpg deleted file mode 100644 index fc92eea8..00000000 Binary files a/screenshots/SL-B-18-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-18-03.jpg b/screenshots/SL-B-18-03.jpg deleted file mode 100644 index 98ab47fd..00000000 Binary files a/screenshots/SL-B-18-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-18-04.jpg b/screenshots/SL-B-18-04.jpg deleted file mode 100644 index e0ae7ecf..00000000 Binary files a/screenshots/SL-B-18-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-18.jpg b/screenshots/SL-B-18.jpg deleted file mode 100644 index 532a44de..00000000 Binary files a/screenshots/SL-B-18.jpg and /dev/null differ diff --git a/screenshots/SL-B-19-00.jpg b/screenshots/SL-B-19-00.jpg deleted file mode 100644 index 640dac8c..00000000 Binary files a/screenshots/SL-B-19-00.jpg and /dev/null differ diff --git a/screenshots/SL-B-19-01.jpg b/screenshots/SL-B-19-01.jpg deleted file mode 100644 index af976d43..00000000 Binary files a/screenshots/SL-B-19-01.jpg and /dev/null differ diff --git a/screenshots/SL-B-19-02.jpg b/screenshots/SL-B-19-02.jpg deleted file mode 100644 index 58f098b6..00000000 Binary files a/screenshots/SL-B-19-02.jpg and /dev/null differ diff --git a/screenshots/SL-B-19-03.jpg b/screenshots/SL-B-19-03.jpg deleted file mode 100644 index d089852f..00000000 Binary files a/screenshots/SL-B-19-03.jpg and /dev/null differ diff --git a/screenshots/SL-B-19-04.jpg b/screenshots/SL-B-19-04.jpg deleted file mode 100644 index 05e2d636..00000000 Binary files a/screenshots/SL-B-19-04.jpg and /dev/null differ diff --git a/screenshots/SL-B-19.jpg b/screenshots/SL-B-19.jpg deleted file mode 100644 index b4c1bd1f..00000000 Binary files a/screenshots/SL-B-19.jpg and /dev/null differ diff --git a/screenshots/SL-C-00-00.jpg b/screenshots/SL-C-00-00.jpg deleted file mode 100644 index 07b09f31..00000000 Binary files a/screenshots/SL-C-00-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-00-01.jpg b/screenshots/SL-C-00-01.jpg deleted file mode 100644 index 74e39a63..00000000 Binary files a/screenshots/SL-C-00-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-00-02.jpg b/screenshots/SL-C-00-02.jpg deleted file mode 100644 index a71e6a37..00000000 Binary files a/screenshots/SL-C-00-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-00-03.jpg b/screenshots/SL-C-00-03.jpg deleted file mode 100644 index 92f87e00..00000000 Binary files a/screenshots/SL-C-00-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-00-04.jpg b/screenshots/SL-C-00-04.jpg deleted file mode 100644 index bc50e4fd..00000000 Binary files a/screenshots/SL-C-00-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-00.jpg b/screenshots/SL-C-00.jpg deleted file mode 100644 index e672a5c9..00000000 Binary files a/screenshots/SL-C-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-01-00.jpg b/screenshots/SL-C-01-00.jpg deleted file mode 100644 index 0b747d68..00000000 Binary files a/screenshots/SL-C-01-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-01-01.jpg b/screenshots/SL-C-01-01.jpg deleted file mode 100644 index 4d8df5fd..00000000 Binary files a/screenshots/SL-C-01-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-01-02.jpg b/screenshots/SL-C-01-02.jpg deleted file mode 100644 index 22c55704..00000000 Binary files a/screenshots/SL-C-01-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-01-03.jpg b/screenshots/SL-C-01-03.jpg deleted file mode 100644 index bbbc0739..00000000 Binary files a/screenshots/SL-C-01-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-01-04.jpg b/screenshots/SL-C-01-04.jpg deleted file mode 100644 index e98c7117..00000000 Binary files a/screenshots/SL-C-01-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-01.jpg b/screenshots/SL-C-01.jpg deleted file mode 100644 index be9de178..00000000 Binary files a/screenshots/SL-C-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-02-00.jpg b/screenshots/SL-C-02-00.jpg deleted file mode 100644 index 71533beb..00000000 Binary files a/screenshots/SL-C-02-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-02-01.jpg b/screenshots/SL-C-02-01.jpg deleted file mode 100644 index fe92293d..00000000 Binary files a/screenshots/SL-C-02-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-02-02.jpg b/screenshots/SL-C-02-02.jpg deleted file mode 100644 index 8af2c439..00000000 Binary files a/screenshots/SL-C-02-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-02-03.jpg b/screenshots/SL-C-02-03.jpg deleted file mode 100644 index 329f9d67..00000000 Binary files a/screenshots/SL-C-02-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-02-04.jpg b/screenshots/SL-C-02-04.jpg deleted file mode 100644 index 20d0c904..00000000 Binary files a/screenshots/SL-C-02-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-02.jpg b/screenshots/SL-C-02.jpg deleted file mode 100644 index 3368962d..00000000 Binary files a/screenshots/SL-C-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-03-00.jpg b/screenshots/SL-C-03-00.jpg deleted file mode 100644 index c344e588..00000000 Binary files a/screenshots/SL-C-03-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-03-01.jpg b/screenshots/SL-C-03-01.jpg deleted file mode 100644 index d70d1322..00000000 Binary files a/screenshots/SL-C-03-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-03-02.jpg b/screenshots/SL-C-03-02.jpg deleted file mode 100644 index 60b36c35..00000000 Binary files a/screenshots/SL-C-03-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-03-03.jpg b/screenshots/SL-C-03-03.jpg deleted file mode 100644 index d6a9293d..00000000 Binary files a/screenshots/SL-C-03-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-03-04.jpg b/screenshots/SL-C-03-04.jpg deleted file mode 100644 index 7a650004..00000000 Binary files a/screenshots/SL-C-03-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-03.jpg b/screenshots/SL-C-03.jpg deleted file mode 100644 index f0b116e2..00000000 Binary files a/screenshots/SL-C-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-04-00.jpg b/screenshots/SL-C-04-00.jpg deleted file mode 100644 index dfccde54..00000000 Binary files a/screenshots/SL-C-04-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-04-01.jpg b/screenshots/SL-C-04-01.jpg deleted file mode 100644 index d2673dbf..00000000 Binary files a/screenshots/SL-C-04-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-04-02.jpg b/screenshots/SL-C-04-02.jpg deleted file mode 100644 index 9ac12723..00000000 Binary files a/screenshots/SL-C-04-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-04-03.jpg b/screenshots/SL-C-04-03.jpg deleted file mode 100644 index c7e18b42..00000000 Binary files a/screenshots/SL-C-04-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-04-04.jpg b/screenshots/SL-C-04-04.jpg deleted file mode 100644 index e1db7a4c..00000000 Binary files a/screenshots/SL-C-04-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-04.jpg b/screenshots/SL-C-04.jpg deleted file mode 100644 index 80a50401..00000000 Binary files a/screenshots/SL-C-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-05-00.jpg b/screenshots/SL-C-05-00.jpg deleted file mode 100644 index 99108660..00000000 Binary files a/screenshots/SL-C-05-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-05-01.jpg b/screenshots/SL-C-05-01.jpg deleted file mode 100644 index 5963780e..00000000 Binary files a/screenshots/SL-C-05-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-05-02.jpg b/screenshots/SL-C-05-02.jpg deleted file mode 100644 index 16d6406e..00000000 Binary files a/screenshots/SL-C-05-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-05-03.jpg b/screenshots/SL-C-05-03.jpg deleted file mode 100644 index 6d4e8fe1..00000000 Binary files a/screenshots/SL-C-05-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-05-04.jpg b/screenshots/SL-C-05-04.jpg deleted file mode 100644 index 912309f1..00000000 Binary files a/screenshots/SL-C-05-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-05.jpg b/screenshots/SL-C-05.jpg deleted file mode 100644 index 912bdc87..00000000 Binary files a/screenshots/SL-C-05.jpg and /dev/null differ diff --git a/screenshots/SL-C-06-00.jpg b/screenshots/SL-C-06-00.jpg deleted file mode 100644 index d367c674..00000000 Binary files a/screenshots/SL-C-06-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-06-01.jpg b/screenshots/SL-C-06-01.jpg deleted file mode 100644 index 40a8daed..00000000 Binary files a/screenshots/SL-C-06-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-06-02.jpg b/screenshots/SL-C-06-02.jpg deleted file mode 100644 index 3b94f003..00000000 Binary files a/screenshots/SL-C-06-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-06-03.jpg b/screenshots/SL-C-06-03.jpg deleted file mode 100644 index d11d2065..00000000 Binary files a/screenshots/SL-C-06-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-06-04.jpg b/screenshots/SL-C-06-04.jpg deleted file mode 100644 index 17a99ebe..00000000 Binary files a/screenshots/SL-C-06-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-06.jpg b/screenshots/SL-C-06.jpg deleted file mode 100644 index 49ca8869..00000000 Binary files a/screenshots/SL-C-06.jpg and /dev/null differ diff --git a/screenshots/SL-C-07-00.jpg b/screenshots/SL-C-07-00.jpg deleted file mode 100644 index 58cb0fb5..00000000 Binary files a/screenshots/SL-C-07-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-07-01.jpg b/screenshots/SL-C-07-01.jpg deleted file mode 100644 index e9a54446..00000000 Binary files a/screenshots/SL-C-07-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-07-02.jpg b/screenshots/SL-C-07-02.jpg deleted file mode 100644 index 9388449b..00000000 Binary files a/screenshots/SL-C-07-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-07-03.jpg b/screenshots/SL-C-07-03.jpg deleted file mode 100644 index 83334313..00000000 Binary files a/screenshots/SL-C-07-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-07-04.jpg b/screenshots/SL-C-07-04.jpg deleted file mode 100644 index 27cb518b..00000000 Binary files a/screenshots/SL-C-07-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-07.jpg b/screenshots/SL-C-07.jpg deleted file mode 100644 index 85a070f2..00000000 Binary files a/screenshots/SL-C-07.jpg and /dev/null differ diff --git a/screenshots/SL-C-08-00.jpg b/screenshots/SL-C-08-00.jpg deleted file mode 100644 index ebb3d9cb..00000000 Binary files a/screenshots/SL-C-08-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-08-01.jpg b/screenshots/SL-C-08-01.jpg deleted file mode 100644 index 654800c5..00000000 Binary files a/screenshots/SL-C-08-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-08-02.jpg b/screenshots/SL-C-08-02.jpg deleted file mode 100644 index 4c2de2ef..00000000 Binary files a/screenshots/SL-C-08-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-08-03.jpg b/screenshots/SL-C-08-03.jpg deleted file mode 100644 index e5e4113b..00000000 Binary files a/screenshots/SL-C-08-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-08-04.jpg b/screenshots/SL-C-08-04.jpg deleted file mode 100644 index 16e513a8..00000000 Binary files a/screenshots/SL-C-08-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-08.jpg b/screenshots/SL-C-08.jpg deleted file mode 100644 index e0ebdb95..00000000 Binary files a/screenshots/SL-C-08.jpg and /dev/null differ diff --git a/screenshots/SL-C-09-00.jpg b/screenshots/SL-C-09-00.jpg deleted file mode 100644 index cf122d1e..00000000 Binary files a/screenshots/SL-C-09-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-09-01.jpg b/screenshots/SL-C-09-01.jpg deleted file mode 100644 index 40d648f3..00000000 Binary files a/screenshots/SL-C-09-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-09-02.jpg b/screenshots/SL-C-09-02.jpg deleted file mode 100644 index d6395504..00000000 Binary files a/screenshots/SL-C-09-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-09-03.jpg b/screenshots/SL-C-09-03.jpg deleted file mode 100644 index 45b8a7c7..00000000 Binary files a/screenshots/SL-C-09-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-09-04.jpg b/screenshots/SL-C-09-04.jpg deleted file mode 100644 index cb91c62a..00000000 Binary files a/screenshots/SL-C-09-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-09.jpg b/screenshots/SL-C-09.jpg deleted file mode 100644 index e5e2ccaf..00000000 Binary files a/screenshots/SL-C-09.jpg and /dev/null differ diff --git a/screenshots/SL-C-10-00.jpg b/screenshots/SL-C-10-00.jpg deleted file mode 100644 index de712917..00000000 Binary files a/screenshots/SL-C-10-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-10-01.jpg b/screenshots/SL-C-10-01.jpg deleted file mode 100644 index 3ea71d20..00000000 Binary files a/screenshots/SL-C-10-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-10-02.jpg b/screenshots/SL-C-10-02.jpg deleted file mode 100644 index e034d83f..00000000 Binary files a/screenshots/SL-C-10-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-10-03.jpg b/screenshots/SL-C-10-03.jpg deleted file mode 100644 index b0e0bdb9..00000000 Binary files a/screenshots/SL-C-10-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-10-04.jpg b/screenshots/SL-C-10-04.jpg deleted file mode 100644 index 8f087ad9..00000000 Binary files a/screenshots/SL-C-10-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-10.jpg b/screenshots/SL-C-10.jpg deleted file mode 100644 index 49d8de60..00000000 Binary files a/screenshots/SL-C-10.jpg and /dev/null differ diff --git a/screenshots/SL-C-11-00.jpg b/screenshots/SL-C-11-00.jpg deleted file mode 100644 index da753142..00000000 Binary files a/screenshots/SL-C-11-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-11-01.jpg b/screenshots/SL-C-11-01.jpg deleted file mode 100644 index 04b9189c..00000000 Binary files a/screenshots/SL-C-11-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-11-02.jpg b/screenshots/SL-C-11-02.jpg deleted file mode 100644 index d4910fa0..00000000 Binary files a/screenshots/SL-C-11-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-11-03.jpg b/screenshots/SL-C-11-03.jpg deleted file mode 100644 index 80662737..00000000 Binary files a/screenshots/SL-C-11-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-11-04.jpg b/screenshots/SL-C-11-04.jpg deleted file mode 100644 index d05e0e4d..00000000 Binary files a/screenshots/SL-C-11-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-11.jpg b/screenshots/SL-C-11.jpg deleted file mode 100644 index 8f2bb18e..00000000 Binary files a/screenshots/SL-C-11.jpg and /dev/null differ diff --git a/screenshots/SL-C-12-00.jpg b/screenshots/SL-C-12-00.jpg deleted file mode 100644 index 5cb6a4f1..00000000 Binary files a/screenshots/SL-C-12-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-12-01.jpg b/screenshots/SL-C-12-01.jpg deleted file mode 100644 index efd1d860..00000000 Binary files a/screenshots/SL-C-12-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-12-02.jpg b/screenshots/SL-C-12-02.jpg deleted file mode 100644 index 800498ff..00000000 Binary files a/screenshots/SL-C-12-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-12-03.jpg b/screenshots/SL-C-12-03.jpg deleted file mode 100644 index bfc2aae3..00000000 Binary files a/screenshots/SL-C-12-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-12-04.jpg b/screenshots/SL-C-12-04.jpg deleted file mode 100644 index 726c7041..00000000 Binary files a/screenshots/SL-C-12-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-12.jpg b/screenshots/SL-C-12.jpg deleted file mode 100644 index d38cd93f..00000000 Binary files a/screenshots/SL-C-12.jpg and /dev/null differ diff --git a/screenshots/SL-C-13-00.jpg b/screenshots/SL-C-13-00.jpg deleted file mode 100644 index 8666fbb7..00000000 Binary files a/screenshots/SL-C-13-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-13-01.jpg b/screenshots/SL-C-13-01.jpg deleted file mode 100644 index f0017aaa..00000000 Binary files a/screenshots/SL-C-13-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-13-02.jpg b/screenshots/SL-C-13-02.jpg deleted file mode 100644 index ada4d47b..00000000 Binary files a/screenshots/SL-C-13-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-13-03.jpg b/screenshots/SL-C-13-03.jpg deleted file mode 100644 index bafc6fbf..00000000 Binary files a/screenshots/SL-C-13-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-13-04.jpg b/screenshots/SL-C-13-04.jpg deleted file mode 100644 index 8584535b..00000000 Binary files a/screenshots/SL-C-13-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-13.jpg b/screenshots/SL-C-13.jpg deleted file mode 100644 index 6262df5a..00000000 Binary files a/screenshots/SL-C-13.jpg and /dev/null differ diff --git a/screenshots/SL-C-14-00.jpg b/screenshots/SL-C-14-00.jpg deleted file mode 100644 index 556d0c76..00000000 Binary files a/screenshots/SL-C-14-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-14-01.jpg b/screenshots/SL-C-14-01.jpg deleted file mode 100644 index 3c30f214..00000000 Binary files a/screenshots/SL-C-14-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-14-02.jpg b/screenshots/SL-C-14-02.jpg deleted file mode 100644 index 622fb7cf..00000000 Binary files a/screenshots/SL-C-14-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-14-03.jpg b/screenshots/SL-C-14-03.jpg deleted file mode 100644 index d4dbc61e..00000000 Binary files a/screenshots/SL-C-14-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-14-04.jpg b/screenshots/SL-C-14-04.jpg deleted file mode 100644 index fda9a253..00000000 Binary files a/screenshots/SL-C-14-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-14.jpg b/screenshots/SL-C-14.jpg deleted file mode 100644 index 6bba8455..00000000 Binary files a/screenshots/SL-C-14.jpg and /dev/null differ diff --git a/screenshots/SL-C-15-00.jpg b/screenshots/SL-C-15-00.jpg deleted file mode 100644 index 02f775db..00000000 Binary files a/screenshots/SL-C-15-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-15-01.jpg b/screenshots/SL-C-15-01.jpg deleted file mode 100644 index 2a0b5a3f..00000000 Binary files a/screenshots/SL-C-15-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-15-02.jpg b/screenshots/SL-C-15-02.jpg deleted file mode 100644 index 84890c99..00000000 Binary files a/screenshots/SL-C-15-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-15-03.jpg b/screenshots/SL-C-15-03.jpg deleted file mode 100644 index 7b3356fc..00000000 Binary files a/screenshots/SL-C-15-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-15-04.jpg b/screenshots/SL-C-15-04.jpg deleted file mode 100644 index 237122a9..00000000 Binary files a/screenshots/SL-C-15-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-15.jpg b/screenshots/SL-C-15.jpg deleted file mode 100644 index 68f56ac7..00000000 Binary files a/screenshots/SL-C-15.jpg and /dev/null differ diff --git a/screenshots/SL-C-16-00.jpg b/screenshots/SL-C-16-00.jpg deleted file mode 100644 index 94fec97e..00000000 Binary files a/screenshots/SL-C-16-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-16-01.jpg b/screenshots/SL-C-16-01.jpg deleted file mode 100644 index 785079c9..00000000 Binary files a/screenshots/SL-C-16-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-16-02.jpg b/screenshots/SL-C-16-02.jpg deleted file mode 100644 index df1ffec6..00000000 Binary files a/screenshots/SL-C-16-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-16-03.jpg b/screenshots/SL-C-16-03.jpg deleted file mode 100644 index 22856f63..00000000 Binary files a/screenshots/SL-C-16-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-16-04.jpg b/screenshots/SL-C-16-04.jpg deleted file mode 100644 index 2585fb12..00000000 Binary files a/screenshots/SL-C-16-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-16.jpg b/screenshots/SL-C-16.jpg deleted file mode 100644 index 10d7760d..00000000 Binary files a/screenshots/SL-C-16.jpg and /dev/null differ diff --git a/screenshots/SL-C-17-00.jpg b/screenshots/SL-C-17-00.jpg deleted file mode 100644 index 3941ab47..00000000 Binary files a/screenshots/SL-C-17-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-17-01.jpg b/screenshots/SL-C-17-01.jpg deleted file mode 100644 index 60359599..00000000 Binary files a/screenshots/SL-C-17-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-17-02.jpg b/screenshots/SL-C-17-02.jpg deleted file mode 100644 index d2e47cd4..00000000 Binary files a/screenshots/SL-C-17-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-17-03.jpg b/screenshots/SL-C-17-03.jpg deleted file mode 100644 index 9c5485cd..00000000 Binary files a/screenshots/SL-C-17-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-17-04.jpg b/screenshots/SL-C-17-04.jpg deleted file mode 100644 index 02ad3fbb..00000000 Binary files a/screenshots/SL-C-17-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-17.jpg b/screenshots/SL-C-17.jpg deleted file mode 100644 index 2a13d3d9..00000000 Binary files a/screenshots/SL-C-17.jpg and /dev/null differ diff --git a/screenshots/SL-C-18-00.jpg b/screenshots/SL-C-18-00.jpg deleted file mode 100644 index 5128bed1..00000000 Binary files a/screenshots/SL-C-18-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-18-01.jpg b/screenshots/SL-C-18-01.jpg deleted file mode 100644 index 4b8ee50a..00000000 Binary files a/screenshots/SL-C-18-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-18-02.jpg b/screenshots/SL-C-18-02.jpg deleted file mode 100644 index c0ad09b2..00000000 Binary files a/screenshots/SL-C-18-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-18-03.jpg b/screenshots/SL-C-18-03.jpg deleted file mode 100644 index f97efda0..00000000 Binary files a/screenshots/SL-C-18-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-18-04.jpg b/screenshots/SL-C-18-04.jpg deleted file mode 100644 index 8d33d0cf..00000000 Binary files a/screenshots/SL-C-18-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-18.jpg b/screenshots/SL-C-18.jpg deleted file mode 100644 index c049539d..00000000 Binary files a/screenshots/SL-C-18.jpg and /dev/null differ diff --git a/screenshots/SL-C-19-00.jpg b/screenshots/SL-C-19-00.jpg deleted file mode 100644 index d3acc1fe..00000000 Binary files a/screenshots/SL-C-19-00.jpg and /dev/null differ diff --git a/screenshots/SL-C-19-01.jpg b/screenshots/SL-C-19-01.jpg deleted file mode 100644 index e8a91c80..00000000 Binary files a/screenshots/SL-C-19-01.jpg and /dev/null differ diff --git a/screenshots/SL-C-19-02.jpg b/screenshots/SL-C-19-02.jpg deleted file mode 100644 index 8ce83f11..00000000 Binary files a/screenshots/SL-C-19-02.jpg and /dev/null differ diff --git a/screenshots/SL-C-19-03.jpg b/screenshots/SL-C-19-03.jpg deleted file mode 100644 index a6bca73f..00000000 Binary files a/screenshots/SL-C-19-03.jpg and /dev/null differ diff --git a/screenshots/SL-C-19-04.jpg b/screenshots/SL-C-19-04.jpg deleted file mode 100644 index 8e6a7131..00000000 Binary files a/screenshots/SL-C-19-04.jpg and /dev/null differ diff --git a/screenshots/SL-C-19.jpg b/screenshots/SL-C-19.jpg deleted file mode 100644 index 974bea18..00000000 Binary files a/screenshots/SL-C-19.jpg and /dev/null differ diff --git a/screenshots/SL-D-00-00.jpg b/screenshots/SL-D-00-00.jpg deleted file mode 100644 index 715f7236..00000000 Binary files a/screenshots/SL-D-00-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-00-01.jpg b/screenshots/SL-D-00-01.jpg deleted file mode 100644 index fbe43afb..00000000 Binary files a/screenshots/SL-D-00-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-00-02.jpg b/screenshots/SL-D-00-02.jpg deleted file mode 100644 index 8d1e7d90..00000000 Binary files a/screenshots/SL-D-00-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-00-03.jpg b/screenshots/SL-D-00-03.jpg deleted file mode 100644 index 9dd01096..00000000 Binary files a/screenshots/SL-D-00-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-00-04.jpg b/screenshots/SL-D-00-04.jpg deleted file mode 100644 index 2528a32f..00000000 Binary files a/screenshots/SL-D-00-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-00.jpg b/screenshots/SL-D-00.jpg deleted file mode 100644 index a00888c3..00000000 Binary files a/screenshots/SL-D-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-01-00.jpg b/screenshots/SL-D-01-00.jpg deleted file mode 100644 index f779b7ef..00000000 Binary files a/screenshots/SL-D-01-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-01-01.jpg b/screenshots/SL-D-01-01.jpg deleted file mode 100644 index bae40b20..00000000 Binary files a/screenshots/SL-D-01-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-01-02.jpg b/screenshots/SL-D-01-02.jpg deleted file mode 100644 index 6576f247..00000000 Binary files a/screenshots/SL-D-01-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-01-03.jpg b/screenshots/SL-D-01-03.jpg deleted file mode 100644 index d5d8f46c..00000000 Binary files a/screenshots/SL-D-01-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-01-04.jpg b/screenshots/SL-D-01-04.jpg deleted file mode 100644 index 05506cf5..00000000 Binary files a/screenshots/SL-D-01-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-01.jpg b/screenshots/SL-D-01.jpg deleted file mode 100644 index 1ad04af0..00000000 Binary files a/screenshots/SL-D-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-02-00.jpg b/screenshots/SL-D-02-00.jpg deleted file mode 100644 index 6602d1e5..00000000 Binary files a/screenshots/SL-D-02-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-02-01.jpg b/screenshots/SL-D-02-01.jpg deleted file mode 100644 index 4fcef0f9..00000000 Binary files a/screenshots/SL-D-02-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-02-02.jpg b/screenshots/SL-D-02-02.jpg deleted file mode 100644 index 8279f7d9..00000000 Binary files a/screenshots/SL-D-02-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-02-03.jpg b/screenshots/SL-D-02-03.jpg deleted file mode 100644 index ac248d43..00000000 Binary files a/screenshots/SL-D-02-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-02-04.jpg b/screenshots/SL-D-02-04.jpg deleted file mode 100644 index 655bcf91..00000000 Binary files a/screenshots/SL-D-02-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-02.jpg b/screenshots/SL-D-02.jpg deleted file mode 100644 index aefda86c..00000000 Binary files a/screenshots/SL-D-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-03-00.jpg b/screenshots/SL-D-03-00.jpg deleted file mode 100644 index b8ff645b..00000000 Binary files a/screenshots/SL-D-03-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-03-01.jpg b/screenshots/SL-D-03-01.jpg deleted file mode 100644 index b8e1f47f..00000000 Binary files a/screenshots/SL-D-03-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-03-02.jpg b/screenshots/SL-D-03-02.jpg deleted file mode 100644 index 78ccbf5c..00000000 Binary files a/screenshots/SL-D-03-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-03-03.jpg b/screenshots/SL-D-03-03.jpg deleted file mode 100644 index ba1f883d..00000000 Binary files a/screenshots/SL-D-03-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-03-04.jpg b/screenshots/SL-D-03-04.jpg deleted file mode 100644 index e171157b..00000000 Binary files a/screenshots/SL-D-03-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-03.jpg b/screenshots/SL-D-03.jpg deleted file mode 100644 index bd8c032d..00000000 Binary files a/screenshots/SL-D-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-04-00.jpg b/screenshots/SL-D-04-00.jpg deleted file mode 100644 index a85c8b99..00000000 Binary files a/screenshots/SL-D-04-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-04-01.jpg b/screenshots/SL-D-04-01.jpg deleted file mode 100644 index 4f95e8ef..00000000 Binary files a/screenshots/SL-D-04-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-04-02.jpg b/screenshots/SL-D-04-02.jpg deleted file mode 100644 index 1ddc39e4..00000000 Binary files a/screenshots/SL-D-04-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-04-03.jpg b/screenshots/SL-D-04-03.jpg deleted file mode 100644 index 805f3aea..00000000 Binary files a/screenshots/SL-D-04-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-04-04.jpg b/screenshots/SL-D-04-04.jpg deleted file mode 100644 index 3ee2d27a..00000000 Binary files a/screenshots/SL-D-04-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-04.jpg b/screenshots/SL-D-04.jpg deleted file mode 100644 index 1fd37a75..00000000 Binary files a/screenshots/SL-D-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-05-00.jpg b/screenshots/SL-D-05-00.jpg deleted file mode 100644 index a7a859dc..00000000 Binary files a/screenshots/SL-D-05-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-05-01.jpg b/screenshots/SL-D-05-01.jpg deleted file mode 100644 index 92c11a40..00000000 Binary files a/screenshots/SL-D-05-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-05-02.jpg b/screenshots/SL-D-05-02.jpg deleted file mode 100644 index 1b4f5b83..00000000 Binary files a/screenshots/SL-D-05-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-05-03.jpg b/screenshots/SL-D-05-03.jpg deleted file mode 100644 index 8200751e..00000000 Binary files a/screenshots/SL-D-05-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-05-04.jpg b/screenshots/SL-D-05-04.jpg deleted file mode 100644 index f91edd37..00000000 Binary files a/screenshots/SL-D-05-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-05.jpg b/screenshots/SL-D-05.jpg deleted file mode 100644 index 0f711534..00000000 Binary files a/screenshots/SL-D-05.jpg and /dev/null differ diff --git a/screenshots/SL-D-06-00.jpg b/screenshots/SL-D-06-00.jpg deleted file mode 100644 index 19440129..00000000 Binary files a/screenshots/SL-D-06-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-06-01.jpg b/screenshots/SL-D-06-01.jpg deleted file mode 100644 index c25b4dac..00000000 Binary files a/screenshots/SL-D-06-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-06-02.jpg b/screenshots/SL-D-06-02.jpg deleted file mode 100644 index d3feb75a..00000000 Binary files a/screenshots/SL-D-06-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-06-03.jpg b/screenshots/SL-D-06-03.jpg deleted file mode 100644 index af2c70ad..00000000 Binary files a/screenshots/SL-D-06-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-06-04.jpg b/screenshots/SL-D-06-04.jpg deleted file mode 100644 index dc8726dc..00000000 Binary files a/screenshots/SL-D-06-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-06.jpg b/screenshots/SL-D-06.jpg deleted file mode 100644 index 29856a29..00000000 Binary files a/screenshots/SL-D-06.jpg and /dev/null differ diff --git a/screenshots/SL-D-07-00.jpg b/screenshots/SL-D-07-00.jpg deleted file mode 100644 index b2204607..00000000 Binary files a/screenshots/SL-D-07-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-07-01.jpg b/screenshots/SL-D-07-01.jpg deleted file mode 100644 index 16de3aa2..00000000 Binary files a/screenshots/SL-D-07-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-07-02.jpg b/screenshots/SL-D-07-02.jpg deleted file mode 100644 index 1c40b75d..00000000 Binary files a/screenshots/SL-D-07-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-07-03.jpg b/screenshots/SL-D-07-03.jpg deleted file mode 100644 index 7862ae35..00000000 Binary files a/screenshots/SL-D-07-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-07-04.jpg b/screenshots/SL-D-07-04.jpg deleted file mode 100644 index eff488bd..00000000 Binary files a/screenshots/SL-D-07-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-07.jpg b/screenshots/SL-D-07.jpg deleted file mode 100644 index 601171dc..00000000 Binary files a/screenshots/SL-D-07.jpg and /dev/null differ diff --git a/screenshots/SL-D-08-00.jpg b/screenshots/SL-D-08-00.jpg deleted file mode 100644 index 3a4f7f94..00000000 Binary files a/screenshots/SL-D-08-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-08-01.jpg b/screenshots/SL-D-08-01.jpg deleted file mode 100644 index 850e77ad..00000000 Binary files a/screenshots/SL-D-08-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-08-02.jpg b/screenshots/SL-D-08-02.jpg deleted file mode 100644 index f67b72e0..00000000 Binary files a/screenshots/SL-D-08-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-08-03.jpg b/screenshots/SL-D-08-03.jpg deleted file mode 100644 index 3ca71649..00000000 Binary files a/screenshots/SL-D-08-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-08-04.jpg b/screenshots/SL-D-08-04.jpg deleted file mode 100644 index 3a015b97..00000000 Binary files a/screenshots/SL-D-08-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-08.jpg b/screenshots/SL-D-08.jpg deleted file mode 100644 index a21b6355..00000000 Binary files a/screenshots/SL-D-08.jpg and /dev/null differ diff --git a/screenshots/SL-D-09-00.jpg b/screenshots/SL-D-09-00.jpg deleted file mode 100644 index ae912073..00000000 Binary files a/screenshots/SL-D-09-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-09-01.jpg b/screenshots/SL-D-09-01.jpg deleted file mode 100644 index 0aa2642d..00000000 Binary files a/screenshots/SL-D-09-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-09-02.jpg b/screenshots/SL-D-09-02.jpg deleted file mode 100644 index f7d7c50b..00000000 Binary files a/screenshots/SL-D-09-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-09-03.jpg b/screenshots/SL-D-09-03.jpg deleted file mode 100644 index 1a969c8d..00000000 Binary files a/screenshots/SL-D-09-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-09-04.jpg b/screenshots/SL-D-09-04.jpg deleted file mode 100644 index e8d5b962..00000000 Binary files a/screenshots/SL-D-09-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-09.jpg b/screenshots/SL-D-09.jpg deleted file mode 100644 index 42c3bfd4..00000000 Binary files a/screenshots/SL-D-09.jpg and /dev/null differ diff --git a/screenshots/SL-D-10-00.jpg b/screenshots/SL-D-10-00.jpg deleted file mode 100644 index 7fa5e225..00000000 Binary files a/screenshots/SL-D-10-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-10-01.jpg b/screenshots/SL-D-10-01.jpg deleted file mode 100644 index 022698cc..00000000 Binary files a/screenshots/SL-D-10-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-10-02.jpg b/screenshots/SL-D-10-02.jpg deleted file mode 100644 index 6258f7e8..00000000 Binary files a/screenshots/SL-D-10-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-10-03.jpg b/screenshots/SL-D-10-03.jpg deleted file mode 100644 index 712bb602..00000000 Binary files a/screenshots/SL-D-10-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-10-04.jpg b/screenshots/SL-D-10-04.jpg deleted file mode 100644 index 65e59191..00000000 Binary files a/screenshots/SL-D-10-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-10.jpg b/screenshots/SL-D-10.jpg deleted file mode 100644 index 8c520ec0..00000000 Binary files a/screenshots/SL-D-10.jpg and /dev/null differ diff --git a/screenshots/SL-D-11-00.jpg b/screenshots/SL-D-11-00.jpg deleted file mode 100644 index 7bc28233..00000000 Binary files a/screenshots/SL-D-11-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-11-01.jpg b/screenshots/SL-D-11-01.jpg deleted file mode 100644 index 43c78ee7..00000000 Binary files a/screenshots/SL-D-11-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-11-02.jpg b/screenshots/SL-D-11-02.jpg deleted file mode 100644 index be7ed214..00000000 Binary files a/screenshots/SL-D-11-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-11-03.jpg b/screenshots/SL-D-11-03.jpg deleted file mode 100644 index 12c1fc70..00000000 Binary files a/screenshots/SL-D-11-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-11-04.jpg b/screenshots/SL-D-11-04.jpg deleted file mode 100644 index 12f58090..00000000 Binary files a/screenshots/SL-D-11-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-11.jpg b/screenshots/SL-D-11.jpg deleted file mode 100644 index 9aec34d1..00000000 Binary files a/screenshots/SL-D-11.jpg and /dev/null differ diff --git a/screenshots/SL-D-12-00.jpg b/screenshots/SL-D-12-00.jpg deleted file mode 100644 index d53c5c61..00000000 Binary files a/screenshots/SL-D-12-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-12-01.jpg b/screenshots/SL-D-12-01.jpg deleted file mode 100644 index 583a033b..00000000 Binary files a/screenshots/SL-D-12-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-12-02.jpg b/screenshots/SL-D-12-02.jpg deleted file mode 100644 index ef6fb098..00000000 Binary files a/screenshots/SL-D-12-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-12-03.jpg b/screenshots/SL-D-12-03.jpg deleted file mode 100644 index 2ab87b5a..00000000 Binary files a/screenshots/SL-D-12-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-12-04.jpg b/screenshots/SL-D-12-04.jpg deleted file mode 100644 index 7d65afd3..00000000 Binary files a/screenshots/SL-D-12-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-12.jpg b/screenshots/SL-D-12.jpg deleted file mode 100644 index 257adb56..00000000 Binary files a/screenshots/SL-D-12.jpg and /dev/null differ diff --git a/screenshots/SL-D-13-00.jpg b/screenshots/SL-D-13-00.jpg deleted file mode 100644 index fc833f99..00000000 Binary files a/screenshots/SL-D-13-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-13-01.jpg b/screenshots/SL-D-13-01.jpg deleted file mode 100644 index 0f4d241a..00000000 Binary files a/screenshots/SL-D-13-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-13-02.jpg b/screenshots/SL-D-13-02.jpg deleted file mode 100644 index fa7c9b45..00000000 Binary files a/screenshots/SL-D-13-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-13-03.jpg b/screenshots/SL-D-13-03.jpg deleted file mode 100644 index 99bfd0ee..00000000 Binary files a/screenshots/SL-D-13-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-13-04.jpg b/screenshots/SL-D-13-04.jpg deleted file mode 100644 index 216eb0ef..00000000 Binary files a/screenshots/SL-D-13-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-13.jpg b/screenshots/SL-D-13.jpg deleted file mode 100644 index fd8f6c4d..00000000 Binary files a/screenshots/SL-D-13.jpg and /dev/null differ diff --git a/screenshots/SL-D-14-00.jpg b/screenshots/SL-D-14-00.jpg deleted file mode 100644 index 3ab986bf..00000000 Binary files a/screenshots/SL-D-14-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-14-01.jpg b/screenshots/SL-D-14-01.jpg deleted file mode 100644 index 4741a499..00000000 Binary files a/screenshots/SL-D-14-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-14-02.jpg b/screenshots/SL-D-14-02.jpg deleted file mode 100644 index 91025289..00000000 Binary files a/screenshots/SL-D-14-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-14-03.jpg b/screenshots/SL-D-14-03.jpg deleted file mode 100644 index 47b6cb8e..00000000 Binary files a/screenshots/SL-D-14-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-14-04.jpg b/screenshots/SL-D-14-04.jpg deleted file mode 100644 index 06bd265b..00000000 Binary files a/screenshots/SL-D-14-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-14.jpg b/screenshots/SL-D-14.jpg deleted file mode 100644 index 08961dc6..00000000 Binary files a/screenshots/SL-D-14.jpg and /dev/null differ diff --git a/screenshots/SL-D-15-00.jpg b/screenshots/SL-D-15-00.jpg deleted file mode 100644 index 725170c6..00000000 Binary files a/screenshots/SL-D-15-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-15-01.jpg b/screenshots/SL-D-15-01.jpg deleted file mode 100644 index e6f4a21d..00000000 Binary files a/screenshots/SL-D-15-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-15-02.jpg b/screenshots/SL-D-15-02.jpg deleted file mode 100644 index cdfc19b3..00000000 Binary files a/screenshots/SL-D-15-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-15-03.jpg b/screenshots/SL-D-15-03.jpg deleted file mode 100644 index f39b6122..00000000 Binary files a/screenshots/SL-D-15-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-15-04.jpg b/screenshots/SL-D-15-04.jpg deleted file mode 100644 index ee15b6f5..00000000 Binary files a/screenshots/SL-D-15-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-15.jpg b/screenshots/SL-D-15.jpg deleted file mode 100644 index 271e8810..00000000 Binary files a/screenshots/SL-D-15.jpg and /dev/null differ diff --git a/screenshots/SL-D-16-00.jpg b/screenshots/SL-D-16-00.jpg deleted file mode 100644 index 4210aaf0..00000000 Binary files a/screenshots/SL-D-16-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-16-01.jpg b/screenshots/SL-D-16-01.jpg deleted file mode 100644 index 7fd48636..00000000 Binary files a/screenshots/SL-D-16-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-16-02.jpg b/screenshots/SL-D-16-02.jpg deleted file mode 100644 index a20b306b..00000000 Binary files a/screenshots/SL-D-16-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-16-03.jpg b/screenshots/SL-D-16-03.jpg deleted file mode 100644 index 5170d1b9..00000000 Binary files a/screenshots/SL-D-16-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-16-04.jpg b/screenshots/SL-D-16-04.jpg deleted file mode 100644 index 99e1f9ad..00000000 Binary files a/screenshots/SL-D-16-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-16.jpg b/screenshots/SL-D-16.jpg deleted file mode 100644 index 6d19becd..00000000 Binary files a/screenshots/SL-D-16.jpg and /dev/null differ diff --git a/screenshots/SL-D-17-00.jpg b/screenshots/SL-D-17-00.jpg deleted file mode 100644 index 6e2fefbb..00000000 Binary files a/screenshots/SL-D-17-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-17-01.jpg b/screenshots/SL-D-17-01.jpg deleted file mode 100644 index 8f9ee3f7..00000000 Binary files a/screenshots/SL-D-17-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-17-02.jpg b/screenshots/SL-D-17-02.jpg deleted file mode 100644 index 7ec8b252..00000000 Binary files a/screenshots/SL-D-17-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-17-03.jpg b/screenshots/SL-D-17-03.jpg deleted file mode 100644 index 8aee9ce1..00000000 Binary files a/screenshots/SL-D-17-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-17-04.jpg b/screenshots/SL-D-17-04.jpg deleted file mode 100644 index 83f259c8..00000000 Binary files a/screenshots/SL-D-17-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-17.jpg b/screenshots/SL-D-17.jpg deleted file mode 100644 index b88a36a3..00000000 Binary files a/screenshots/SL-D-17.jpg and /dev/null differ diff --git a/screenshots/SL-D-18-00.jpg b/screenshots/SL-D-18-00.jpg deleted file mode 100644 index 232762cb..00000000 Binary files a/screenshots/SL-D-18-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-18-01.jpg b/screenshots/SL-D-18-01.jpg deleted file mode 100644 index ea7bda74..00000000 Binary files a/screenshots/SL-D-18-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-18-02.jpg b/screenshots/SL-D-18-02.jpg deleted file mode 100644 index be35d163..00000000 Binary files a/screenshots/SL-D-18-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-18-03.jpg b/screenshots/SL-D-18-03.jpg deleted file mode 100644 index e43fd9ea..00000000 Binary files a/screenshots/SL-D-18-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-18-04.jpg b/screenshots/SL-D-18-04.jpg deleted file mode 100644 index fb87fda9..00000000 Binary files a/screenshots/SL-D-18-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-18.jpg b/screenshots/SL-D-18.jpg deleted file mode 100644 index 6ac8193b..00000000 Binary files a/screenshots/SL-D-18.jpg and /dev/null differ diff --git a/screenshots/SL-D-19-00.jpg b/screenshots/SL-D-19-00.jpg deleted file mode 100644 index 3a245313..00000000 Binary files a/screenshots/SL-D-19-00.jpg and /dev/null differ diff --git a/screenshots/SL-D-19-01.jpg b/screenshots/SL-D-19-01.jpg deleted file mode 100644 index a86b9b06..00000000 Binary files a/screenshots/SL-D-19-01.jpg and /dev/null differ diff --git a/screenshots/SL-D-19-02.jpg b/screenshots/SL-D-19-02.jpg deleted file mode 100644 index 37e57e16..00000000 Binary files a/screenshots/SL-D-19-02.jpg and /dev/null differ diff --git a/screenshots/SL-D-19-03.jpg b/screenshots/SL-D-19-03.jpg deleted file mode 100644 index 06eea35e..00000000 Binary files a/screenshots/SL-D-19-03.jpg and /dev/null differ diff --git a/screenshots/SL-D-19-04.jpg b/screenshots/SL-D-19-04.jpg deleted file mode 100644 index c486c5b6..00000000 Binary files a/screenshots/SL-D-19-04.jpg and /dev/null differ diff --git a/screenshots/SL-D-19.jpg b/screenshots/SL-D-19.jpg deleted file mode 100644 index 2cc3d33b..00000000 Binary files a/screenshots/SL-D-19.jpg and /dev/null differ diff --git a/screenshots/SL-E-00-00.jpg b/screenshots/SL-E-00-00.jpg deleted file mode 100644 index 44451ea3..00000000 Binary files a/screenshots/SL-E-00-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-00-01.jpg b/screenshots/SL-E-00-01.jpg deleted file mode 100644 index 5946daaa..00000000 Binary files a/screenshots/SL-E-00-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-00-02.jpg b/screenshots/SL-E-00-02.jpg deleted file mode 100644 index 5db24a31..00000000 Binary files a/screenshots/SL-E-00-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-00-03.jpg b/screenshots/SL-E-00-03.jpg deleted file mode 100644 index 4e8ffdaa..00000000 Binary files a/screenshots/SL-E-00-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-00-04.jpg b/screenshots/SL-E-00-04.jpg deleted file mode 100644 index 13db1344..00000000 Binary files a/screenshots/SL-E-00-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-00.jpg b/screenshots/SL-E-00.jpg deleted file mode 100644 index b074865c..00000000 Binary files a/screenshots/SL-E-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-01-00.jpg b/screenshots/SL-E-01-00.jpg deleted file mode 100644 index 90c8987e..00000000 Binary files a/screenshots/SL-E-01-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-01-01.jpg b/screenshots/SL-E-01-01.jpg deleted file mode 100644 index 770e5fc8..00000000 Binary files a/screenshots/SL-E-01-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-01-02.jpg b/screenshots/SL-E-01-02.jpg deleted file mode 100644 index 5976e16e..00000000 Binary files a/screenshots/SL-E-01-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-01-03.jpg b/screenshots/SL-E-01-03.jpg deleted file mode 100644 index 1b0e86a1..00000000 Binary files a/screenshots/SL-E-01-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-01-04.jpg b/screenshots/SL-E-01-04.jpg deleted file mode 100644 index 1302418d..00000000 Binary files a/screenshots/SL-E-01-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-01.jpg b/screenshots/SL-E-01.jpg deleted file mode 100644 index 8a2482da..00000000 Binary files a/screenshots/SL-E-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-02-00.jpg b/screenshots/SL-E-02-00.jpg deleted file mode 100644 index b40d4d26..00000000 Binary files a/screenshots/SL-E-02-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-02-01.jpg b/screenshots/SL-E-02-01.jpg deleted file mode 100644 index abbc813c..00000000 Binary files a/screenshots/SL-E-02-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-02-02.jpg b/screenshots/SL-E-02-02.jpg deleted file mode 100644 index be5f4a02..00000000 Binary files a/screenshots/SL-E-02-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-02-03.jpg b/screenshots/SL-E-02-03.jpg deleted file mode 100644 index d430306c..00000000 Binary files a/screenshots/SL-E-02-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-02-04.jpg b/screenshots/SL-E-02-04.jpg deleted file mode 100644 index 41ce0622..00000000 Binary files a/screenshots/SL-E-02-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-02.jpg b/screenshots/SL-E-02.jpg deleted file mode 100644 index 8bcf3520..00000000 Binary files a/screenshots/SL-E-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-03-00.jpg b/screenshots/SL-E-03-00.jpg deleted file mode 100644 index 385d2852..00000000 Binary files a/screenshots/SL-E-03-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-03-01.jpg b/screenshots/SL-E-03-01.jpg deleted file mode 100644 index 9acefbee..00000000 Binary files a/screenshots/SL-E-03-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-03-02.jpg b/screenshots/SL-E-03-02.jpg deleted file mode 100644 index d8d10cab..00000000 Binary files a/screenshots/SL-E-03-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-03-03.jpg b/screenshots/SL-E-03-03.jpg deleted file mode 100644 index 2b14e76b..00000000 Binary files a/screenshots/SL-E-03-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-03-04.jpg b/screenshots/SL-E-03-04.jpg deleted file mode 100644 index a26600bd..00000000 Binary files a/screenshots/SL-E-03-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-03.jpg b/screenshots/SL-E-03.jpg deleted file mode 100644 index c6b55fcf..00000000 Binary files a/screenshots/SL-E-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-04-00.jpg b/screenshots/SL-E-04-00.jpg deleted file mode 100644 index 28dcd6c6..00000000 Binary files a/screenshots/SL-E-04-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-04-01.jpg b/screenshots/SL-E-04-01.jpg deleted file mode 100644 index 178e2460..00000000 Binary files a/screenshots/SL-E-04-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-04-02.jpg b/screenshots/SL-E-04-02.jpg deleted file mode 100644 index df000eb7..00000000 Binary files a/screenshots/SL-E-04-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-04-03.jpg b/screenshots/SL-E-04-03.jpg deleted file mode 100644 index e9f68722..00000000 Binary files a/screenshots/SL-E-04-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-04-04.jpg b/screenshots/SL-E-04-04.jpg deleted file mode 100644 index d65656d3..00000000 Binary files a/screenshots/SL-E-04-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-04.jpg b/screenshots/SL-E-04.jpg deleted file mode 100644 index 5b4374fb..00000000 Binary files a/screenshots/SL-E-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-05-00.jpg b/screenshots/SL-E-05-00.jpg deleted file mode 100644 index ef42d9b1..00000000 Binary files a/screenshots/SL-E-05-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-05-01.jpg b/screenshots/SL-E-05-01.jpg deleted file mode 100644 index 073a9cde..00000000 Binary files a/screenshots/SL-E-05-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-05-02.jpg b/screenshots/SL-E-05-02.jpg deleted file mode 100644 index c1db9706..00000000 Binary files a/screenshots/SL-E-05-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-05-03.jpg b/screenshots/SL-E-05-03.jpg deleted file mode 100644 index c6cbef54..00000000 Binary files a/screenshots/SL-E-05-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-05-04.jpg b/screenshots/SL-E-05-04.jpg deleted file mode 100644 index e6cb341a..00000000 Binary files a/screenshots/SL-E-05-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-05.jpg b/screenshots/SL-E-05.jpg deleted file mode 100644 index ebf3cd8c..00000000 Binary files a/screenshots/SL-E-05.jpg and /dev/null differ diff --git a/screenshots/SL-E-06-00.jpg b/screenshots/SL-E-06-00.jpg deleted file mode 100644 index f6e78eb7..00000000 Binary files a/screenshots/SL-E-06-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-06-01.jpg b/screenshots/SL-E-06-01.jpg deleted file mode 100644 index f3f95133..00000000 Binary files a/screenshots/SL-E-06-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-06-02.jpg b/screenshots/SL-E-06-02.jpg deleted file mode 100644 index 4cc53758..00000000 Binary files a/screenshots/SL-E-06-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-06-03.jpg b/screenshots/SL-E-06-03.jpg deleted file mode 100644 index 8fecbd94..00000000 Binary files a/screenshots/SL-E-06-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-06-04.jpg b/screenshots/SL-E-06-04.jpg deleted file mode 100644 index fa794926..00000000 Binary files a/screenshots/SL-E-06-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-06.jpg b/screenshots/SL-E-06.jpg deleted file mode 100644 index a5da7d90..00000000 Binary files a/screenshots/SL-E-06.jpg and /dev/null differ diff --git a/screenshots/SL-E-07-00.jpg b/screenshots/SL-E-07-00.jpg deleted file mode 100644 index 558fe77a..00000000 Binary files a/screenshots/SL-E-07-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-07-01.jpg b/screenshots/SL-E-07-01.jpg deleted file mode 100644 index b291eeec..00000000 Binary files a/screenshots/SL-E-07-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-07-02.jpg b/screenshots/SL-E-07-02.jpg deleted file mode 100644 index 50715f51..00000000 Binary files a/screenshots/SL-E-07-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-07-03.jpg b/screenshots/SL-E-07-03.jpg deleted file mode 100644 index 6a2496f1..00000000 Binary files a/screenshots/SL-E-07-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-07-04.jpg b/screenshots/SL-E-07-04.jpg deleted file mode 100644 index 3b2d5595..00000000 Binary files a/screenshots/SL-E-07-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-07.jpg b/screenshots/SL-E-07.jpg deleted file mode 100644 index eb7e9928..00000000 Binary files a/screenshots/SL-E-07.jpg and /dev/null differ diff --git a/screenshots/SL-E-08-00.jpg b/screenshots/SL-E-08-00.jpg deleted file mode 100644 index d80f5177..00000000 Binary files a/screenshots/SL-E-08-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-08-01.jpg b/screenshots/SL-E-08-01.jpg deleted file mode 100644 index ac727202..00000000 Binary files a/screenshots/SL-E-08-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-08-02.jpg b/screenshots/SL-E-08-02.jpg deleted file mode 100644 index 83962771..00000000 Binary files a/screenshots/SL-E-08-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-08-03.jpg b/screenshots/SL-E-08-03.jpg deleted file mode 100644 index 37087a82..00000000 Binary files a/screenshots/SL-E-08-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-08-04.jpg b/screenshots/SL-E-08-04.jpg deleted file mode 100644 index 18d91159..00000000 Binary files a/screenshots/SL-E-08-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-08.jpg b/screenshots/SL-E-08.jpg deleted file mode 100644 index 3f46e40f..00000000 Binary files a/screenshots/SL-E-08.jpg and /dev/null differ diff --git a/screenshots/SL-E-09-00.jpg b/screenshots/SL-E-09-00.jpg deleted file mode 100644 index 71ef4bf1..00000000 Binary files a/screenshots/SL-E-09-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-09-01.jpg b/screenshots/SL-E-09-01.jpg deleted file mode 100644 index b9f8e203..00000000 Binary files a/screenshots/SL-E-09-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-09-02.jpg b/screenshots/SL-E-09-02.jpg deleted file mode 100644 index e4121ec1..00000000 Binary files a/screenshots/SL-E-09-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-09-03.jpg b/screenshots/SL-E-09-03.jpg deleted file mode 100644 index 33f13fb7..00000000 Binary files a/screenshots/SL-E-09-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-09-04.jpg b/screenshots/SL-E-09-04.jpg deleted file mode 100644 index 72d7f4cc..00000000 Binary files a/screenshots/SL-E-09-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-09.jpg b/screenshots/SL-E-09.jpg deleted file mode 100644 index 96aa2be9..00000000 Binary files a/screenshots/SL-E-09.jpg and /dev/null differ diff --git a/screenshots/SL-E-10-00.jpg b/screenshots/SL-E-10-00.jpg deleted file mode 100644 index 61724bfb..00000000 Binary files a/screenshots/SL-E-10-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-10-01.jpg b/screenshots/SL-E-10-01.jpg deleted file mode 100644 index e0c787f8..00000000 Binary files a/screenshots/SL-E-10-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-10-02.jpg b/screenshots/SL-E-10-02.jpg deleted file mode 100644 index 44e727f9..00000000 Binary files a/screenshots/SL-E-10-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-10-03.jpg b/screenshots/SL-E-10-03.jpg deleted file mode 100644 index d16cc087..00000000 Binary files a/screenshots/SL-E-10-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-10-04.jpg b/screenshots/SL-E-10-04.jpg deleted file mode 100644 index 10f76339..00000000 Binary files a/screenshots/SL-E-10-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-10.jpg b/screenshots/SL-E-10.jpg deleted file mode 100644 index 075bcb28..00000000 Binary files a/screenshots/SL-E-10.jpg and /dev/null differ diff --git a/screenshots/SL-E-11-00.jpg b/screenshots/SL-E-11-00.jpg deleted file mode 100644 index 6a01aed7..00000000 Binary files a/screenshots/SL-E-11-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-11-01.jpg b/screenshots/SL-E-11-01.jpg deleted file mode 100644 index d8eb1e28..00000000 Binary files a/screenshots/SL-E-11-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-11-02.jpg b/screenshots/SL-E-11-02.jpg deleted file mode 100644 index 24e397e7..00000000 Binary files a/screenshots/SL-E-11-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-11-03.jpg b/screenshots/SL-E-11-03.jpg deleted file mode 100644 index aa34a340..00000000 Binary files a/screenshots/SL-E-11-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-11-04.jpg b/screenshots/SL-E-11-04.jpg deleted file mode 100644 index a20adb99..00000000 Binary files a/screenshots/SL-E-11-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-11.jpg b/screenshots/SL-E-11.jpg deleted file mode 100644 index 93ae36b7..00000000 Binary files a/screenshots/SL-E-11.jpg and /dev/null differ diff --git a/screenshots/SL-E-12-00.jpg b/screenshots/SL-E-12-00.jpg deleted file mode 100644 index 1df2d0a0..00000000 Binary files a/screenshots/SL-E-12-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-12-01.jpg b/screenshots/SL-E-12-01.jpg deleted file mode 100644 index ee7d9f2b..00000000 Binary files a/screenshots/SL-E-12-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-12-02.jpg b/screenshots/SL-E-12-02.jpg deleted file mode 100644 index 40d4f814..00000000 Binary files a/screenshots/SL-E-12-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-12-03.jpg b/screenshots/SL-E-12-03.jpg deleted file mode 100644 index 02413b6f..00000000 Binary files a/screenshots/SL-E-12-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-12-04.jpg b/screenshots/SL-E-12-04.jpg deleted file mode 100644 index 77f7bc20..00000000 Binary files a/screenshots/SL-E-12-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-12.jpg b/screenshots/SL-E-12.jpg deleted file mode 100644 index 225bc148..00000000 Binary files a/screenshots/SL-E-12.jpg and /dev/null differ diff --git a/screenshots/SL-E-13-00.jpg b/screenshots/SL-E-13-00.jpg deleted file mode 100644 index 4f77d74b..00000000 Binary files a/screenshots/SL-E-13-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-13-01.jpg b/screenshots/SL-E-13-01.jpg deleted file mode 100644 index 87be5825..00000000 Binary files a/screenshots/SL-E-13-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-13-02.jpg b/screenshots/SL-E-13-02.jpg deleted file mode 100644 index de2a230c..00000000 Binary files a/screenshots/SL-E-13-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-13-03.jpg b/screenshots/SL-E-13-03.jpg deleted file mode 100644 index 0a7cd745..00000000 Binary files a/screenshots/SL-E-13-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-13-04.jpg b/screenshots/SL-E-13-04.jpg deleted file mode 100644 index f324660b..00000000 Binary files a/screenshots/SL-E-13-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-13.jpg b/screenshots/SL-E-13.jpg deleted file mode 100644 index 965c3b07..00000000 Binary files a/screenshots/SL-E-13.jpg and /dev/null differ diff --git a/screenshots/SL-E-14-00.jpg b/screenshots/SL-E-14-00.jpg deleted file mode 100644 index a0bdc393..00000000 Binary files a/screenshots/SL-E-14-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-14-01.jpg b/screenshots/SL-E-14-01.jpg deleted file mode 100644 index 1e229902..00000000 Binary files a/screenshots/SL-E-14-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-14-02.jpg b/screenshots/SL-E-14-02.jpg deleted file mode 100644 index 779c04f4..00000000 Binary files a/screenshots/SL-E-14-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-14-03.jpg b/screenshots/SL-E-14-03.jpg deleted file mode 100644 index b735d426..00000000 Binary files a/screenshots/SL-E-14-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-14-04.jpg b/screenshots/SL-E-14-04.jpg deleted file mode 100644 index 705a7865..00000000 Binary files a/screenshots/SL-E-14-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-14.jpg b/screenshots/SL-E-14.jpg deleted file mode 100644 index e9b96d96..00000000 Binary files a/screenshots/SL-E-14.jpg and /dev/null differ diff --git a/screenshots/SL-E-15-00.jpg b/screenshots/SL-E-15-00.jpg deleted file mode 100644 index 496d4257..00000000 Binary files a/screenshots/SL-E-15-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-15-01.jpg b/screenshots/SL-E-15-01.jpg deleted file mode 100644 index ce98a42d..00000000 Binary files a/screenshots/SL-E-15-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-15-02.jpg b/screenshots/SL-E-15-02.jpg deleted file mode 100644 index ac9739f7..00000000 Binary files a/screenshots/SL-E-15-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-15-03.jpg b/screenshots/SL-E-15-03.jpg deleted file mode 100644 index ea86c934..00000000 Binary files a/screenshots/SL-E-15-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-15-04.jpg b/screenshots/SL-E-15-04.jpg deleted file mode 100644 index 47c3a956..00000000 Binary files a/screenshots/SL-E-15-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-15.jpg b/screenshots/SL-E-15.jpg deleted file mode 100644 index 5fd9f201..00000000 Binary files a/screenshots/SL-E-15.jpg and /dev/null differ diff --git a/screenshots/SL-E-16-00.jpg b/screenshots/SL-E-16-00.jpg deleted file mode 100644 index 59bfeb17..00000000 Binary files a/screenshots/SL-E-16-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-16-01.jpg b/screenshots/SL-E-16-01.jpg deleted file mode 100644 index 5393a898..00000000 Binary files a/screenshots/SL-E-16-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-16-02.jpg b/screenshots/SL-E-16-02.jpg deleted file mode 100644 index 03085e92..00000000 Binary files a/screenshots/SL-E-16-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-16-03.jpg b/screenshots/SL-E-16-03.jpg deleted file mode 100644 index 17b24521..00000000 Binary files a/screenshots/SL-E-16-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-16-04.jpg b/screenshots/SL-E-16-04.jpg deleted file mode 100644 index 813ec997..00000000 Binary files a/screenshots/SL-E-16-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-16.jpg b/screenshots/SL-E-16.jpg deleted file mode 100644 index 9db866dc..00000000 Binary files a/screenshots/SL-E-16.jpg and /dev/null differ diff --git a/screenshots/SL-E-17-00.jpg b/screenshots/SL-E-17-00.jpg deleted file mode 100644 index e0e82b2a..00000000 Binary files a/screenshots/SL-E-17-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-17-01.jpg b/screenshots/SL-E-17-01.jpg deleted file mode 100644 index 1fe9c8af..00000000 Binary files a/screenshots/SL-E-17-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-17-02.jpg b/screenshots/SL-E-17-02.jpg deleted file mode 100644 index 873a8fa3..00000000 Binary files a/screenshots/SL-E-17-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-17-03.jpg b/screenshots/SL-E-17-03.jpg deleted file mode 100644 index 8b5c3224..00000000 Binary files a/screenshots/SL-E-17-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-17-04.jpg b/screenshots/SL-E-17-04.jpg deleted file mode 100644 index bdf3113f..00000000 Binary files a/screenshots/SL-E-17-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-17.jpg b/screenshots/SL-E-17.jpg deleted file mode 100644 index 8de9da7c..00000000 Binary files a/screenshots/SL-E-17.jpg and /dev/null differ diff --git a/screenshots/SL-E-18-00.jpg b/screenshots/SL-E-18-00.jpg deleted file mode 100644 index 3f368604..00000000 Binary files a/screenshots/SL-E-18-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-18-01.jpg b/screenshots/SL-E-18-01.jpg deleted file mode 100644 index dfcaaa95..00000000 Binary files a/screenshots/SL-E-18-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-18-02.jpg b/screenshots/SL-E-18-02.jpg deleted file mode 100644 index f447da17..00000000 Binary files a/screenshots/SL-E-18-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-18-03.jpg b/screenshots/SL-E-18-03.jpg deleted file mode 100644 index e53ad84d..00000000 Binary files a/screenshots/SL-E-18-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-18-04.jpg b/screenshots/SL-E-18-04.jpg deleted file mode 100644 index 7b4d99b2..00000000 Binary files a/screenshots/SL-E-18-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-18.jpg b/screenshots/SL-E-18.jpg deleted file mode 100644 index 68d1276f..00000000 Binary files a/screenshots/SL-E-18.jpg and /dev/null differ diff --git a/screenshots/SL-E-19-00.jpg b/screenshots/SL-E-19-00.jpg deleted file mode 100644 index b75624ff..00000000 Binary files a/screenshots/SL-E-19-00.jpg and /dev/null differ diff --git a/screenshots/SL-E-19-01.jpg b/screenshots/SL-E-19-01.jpg deleted file mode 100644 index 95dfff20..00000000 Binary files a/screenshots/SL-E-19-01.jpg and /dev/null differ diff --git a/screenshots/SL-E-19-02.jpg b/screenshots/SL-E-19-02.jpg deleted file mode 100644 index 85fca106..00000000 Binary files a/screenshots/SL-E-19-02.jpg and /dev/null differ diff --git a/screenshots/SL-E-19-03.jpg b/screenshots/SL-E-19-03.jpg deleted file mode 100644 index 17c8b2dc..00000000 Binary files a/screenshots/SL-E-19-03.jpg and /dev/null differ diff --git a/screenshots/SL-E-19-04.jpg b/screenshots/SL-E-19-04.jpg deleted file mode 100644 index e916b650..00000000 Binary files a/screenshots/SL-E-19-04.jpg and /dev/null differ diff --git a/screenshots/SL-E-19.jpg b/screenshots/SL-E-19.jpg deleted file mode 100644 index 04f5197c..00000000 Binary files a/screenshots/SL-E-19.jpg and /dev/null differ diff --git a/screenshots/SL-X-00-00.jpg b/screenshots/SL-X-00-00.jpg deleted file mode 100644 index 746b2467..00000000 Binary files a/screenshots/SL-X-00-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-00-01.jpg b/screenshots/SL-X-00-01.jpg deleted file mode 100644 index b8cd62d0..00000000 Binary files a/screenshots/SL-X-00-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-00-02.jpg b/screenshots/SL-X-00-02.jpg deleted file mode 100644 index df411847..00000000 Binary files a/screenshots/SL-X-00-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-00-03.jpg b/screenshots/SL-X-00-03.jpg deleted file mode 100644 index fdf19ce1..00000000 Binary files a/screenshots/SL-X-00-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-00-04.jpg b/screenshots/SL-X-00-04.jpg deleted file mode 100644 index eceadc38..00000000 Binary files a/screenshots/SL-X-00-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-00.jpg b/screenshots/SL-X-00.jpg deleted file mode 100644 index 9fb01ff7..00000000 Binary files a/screenshots/SL-X-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-01-00.jpg b/screenshots/SL-X-01-00.jpg deleted file mode 100644 index c8956ea0..00000000 Binary files a/screenshots/SL-X-01-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-01-01.jpg b/screenshots/SL-X-01-01.jpg deleted file mode 100644 index efcb0b02..00000000 Binary files a/screenshots/SL-X-01-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-01-02.jpg b/screenshots/SL-X-01-02.jpg deleted file mode 100644 index 4de5d48e..00000000 Binary files a/screenshots/SL-X-01-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-01-03.jpg b/screenshots/SL-X-01-03.jpg deleted file mode 100644 index e16a1d73..00000000 Binary files a/screenshots/SL-X-01-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-01-04.jpg b/screenshots/SL-X-01-04.jpg deleted file mode 100644 index 7afb21f7..00000000 Binary files a/screenshots/SL-X-01-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-01.jpg b/screenshots/SL-X-01.jpg deleted file mode 100644 index 67fc908c..00000000 Binary files a/screenshots/SL-X-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-02-00.jpg b/screenshots/SL-X-02-00.jpg deleted file mode 100644 index 2ef2d182..00000000 Binary files a/screenshots/SL-X-02-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-02-01.jpg b/screenshots/SL-X-02-01.jpg deleted file mode 100644 index f3b8756a..00000000 Binary files a/screenshots/SL-X-02-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-02-02.jpg b/screenshots/SL-X-02-02.jpg deleted file mode 100644 index 85d97a53..00000000 Binary files a/screenshots/SL-X-02-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-02-03.jpg b/screenshots/SL-X-02-03.jpg deleted file mode 100644 index 18c802dd..00000000 Binary files a/screenshots/SL-X-02-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-02-04.jpg b/screenshots/SL-X-02-04.jpg deleted file mode 100644 index 759f8ba6..00000000 Binary files a/screenshots/SL-X-02-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-02.jpg b/screenshots/SL-X-02.jpg deleted file mode 100644 index f613cb4c..00000000 Binary files a/screenshots/SL-X-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-03-00.jpg b/screenshots/SL-X-03-00.jpg deleted file mode 100644 index 436061b2..00000000 Binary files a/screenshots/SL-X-03-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-03-01.jpg b/screenshots/SL-X-03-01.jpg deleted file mode 100644 index 24ea4caf..00000000 Binary files a/screenshots/SL-X-03-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-03-02.jpg b/screenshots/SL-X-03-02.jpg deleted file mode 100644 index da923b9e..00000000 Binary files a/screenshots/SL-X-03-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-03-03.jpg b/screenshots/SL-X-03-03.jpg deleted file mode 100644 index b61816b6..00000000 Binary files a/screenshots/SL-X-03-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-03-04.jpg b/screenshots/SL-X-03-04.jpg deleted file mode 100644 index 40d415c1..00000000 Binary files a/screenshots/SL-X-03-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-03.jpg b/screenshots/SL-X-03.jpg deleted file mode 100644 index 2b8a7c6a..00000000 Binary files a/screenshots/SL-X-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-04-00.jpg b/screenshots/SL-X-04-00.jpg deleted file mode 100644 index 78f143fe..00000000 Binary files a/screenshots/SL-X-04-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-04-01.jpg b/screenshots/SL-X-04-01.jpg deleted file mode 100644 index 1c2f60e9..00000000 Binary files a/screenshots/SL-X-04-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-04-02.jpg b/screenshots/SL-X-04-02.jpg deleted file mode 100644 index 9d9f88a4..00000000 Binary files a/screenshots/SL-X-04-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-04-03.jpg b/screenshots/SL-X-04-03.jpg deleted file mode 100644 index c4841321..00000000 Binary files a/screenshots/SL-X-04-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-04-04.jpg b/screenshots/SL-X-04-04.jpg deleted file mode 100644 index c28dac8d..00000000 Binary files a/screenshots/SL-X-04-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-04.jpg b/screenshots/SL-X-04.jpg deleted file mode 100644 index 9558a2c5..00000000 Binary files a/screenshots/SL-X-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-05-00.jpg b/screenshots/SL-X-05-00.jpg deleted file mode 100644 index a1c55c9d..00000000 Binary files a/screenshots/SL-X-05-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-05-01.jpg b/screenshots/SL-X-05-01.jpg deleted file mode 100644 index bac2265f..00000000 Binary files a/screenshots/SL-X-05-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-05-02.jpg b/screenshots/SL-X-05-02.jpg deleted file mode 100644 index b5e2c0c0..00000000 Binary files a/screenshots/SL-X-05-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-05-03.jpg b/screenshots/SL-X-05-03.jpg deleted file mode 100644 index 7471fd3d..00000000 Binary files a/screenshots/SL-X-05-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-05-04.jpg b/screenshots/SL-X-05-04.jpg deleted file mode 100644 index fc09992f..00000000 Binary files a/screenshots/SL-X-05-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-05.jpg b/screenshots/SL-X-05.jpg deleted file mode 100644 index d9789c18..00000000 Binary files a/screenshots/SL-X-05.jpg and /dev/null differ diff --git a/screenshots/SL-X-06-00.jpg b/screenshots/SL-X-06-00.jpg deleted file mode 100644 index 4bd467df..00000000 Binary files a/screenshots/SL-X-06-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-06-01.jpg b/screenshots/SL-X-06-01.jpg deleted file mode 100644 index c1730077..00000000 Binary files a/screenshots/SL-X-06-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-06-02.jpg b/screenshots/SL-X-06-02.jpg deleted file mode 100644 index 69d69915..00000000 Binary files a/screenshots/SL-X-06-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-06-03.jpg b/screenshots/SL-X-06-03.jpg deleted file mode 100644 index be03e145..00000000 Binary files a/screenshots/SL-X-06-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-06-04.jpg b/screenshots/SL-X-06-04.jpg deleted file mode 100644 index 988ea365..00000000 Binary files a/screenshots/SL-X-06-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-06.jpg b/screenshots/SL-X-06.jpg deleted file mode 100644 index bf5054a4..00000000 Binary files a/screenshots/SL-X-06.jpg and /dev/null differ diff --git a/screenshots/SL-X-07-00.jpg b/screenshots/SL-X-07-00.jpg deleted file mode 100644 index 86d218b5..00000000 Binary files a/screenshots/SL-X-07-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-07-01.jpg b/screenshots/SL-X-07-01.jpg deleted file mode 100644 index bb463b01..00000000 Binary files a/screenshots/SL-X-07-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-07-02.jpg b/screenshots/SL-X-07-02.jpg deleted file mode 100644 index 0c4d0f34..00000000 Binary files a/screenshots/SL-X-07-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-07-03.jpg b/screenshots/SL-X-07-03.jpg deleted file mode 100644 index fc3d0111..00000000 Binary files a/screenshots/SL-X-07-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-07-04.jpg b/screenshots/SL-X-07-04.jpg deleted file mode 100644 index 06d285a3..00000000 Binary files a/screenshots/SL-X-07-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-07.jpg b/screenshots/SL-X-07.jpg deleted file mode 100644 index a752ef5a..00000000 Binary files a/screenshots/SL-X-07.jpg and /dev/null differ diff --git a/screenshots/SL-X-08-00.jpg b/screenshots/SL-X-08-00.jpg deleted file mode 100644 index 67069aa6..00000000 Binary files a/screenshots/SL-X-08-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-08-01.jpg b/screenshots/SL-X-08-01.jpg deleted file mode 100644 index b085f1e9..00000000 Binary files a/screenshots/SL-X-08-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-08-02.jpg b/screenshots/SL-X-08-02.jpg deleted file mode 100644 index 24b26340..00000000 Binary files a/screenshots/SL-X-08-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-08-03.jpg b/screenshots/SL-X-08-03.jpg deleted file mode 100644 index 6821f480..00000000 Binary files a/screenshots/SL-X-08-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-08-04.jpg b/screenshots/SL-X-08-04.jpg deleted file mode 100644 index 56c0fa10..00000000 Binary files a/screenshots/SL-X-08-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-08.jpg b/screenshots/SL-X-08.jpg deleted file mode 100644 index cb7db34e..00000000 Binary files a/screenshots/SL-X-08.jpg and /dev/null differ diff --git a/screenshots/SL-X-09-00.jpg b/screenshots/SL-X-09-00.jpg deleted file mode 100644 index 4b4165ff..00000000 Binary files a/screenshots/SL-X-09-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-09-01.jpg b/screenshots/SL-X-09-01.jpg deleted file mode 100644 index 3d56186f..00000000 Binary files a/screenshots/SL-X-09-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-09-02.jpg b/screenshots/SL-X-09-02.jpg deleted file mode 100644 index c9ce6326..00000000 Binary files a/screenshots/SL-X-09-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-09-03.jpg b/screenshots/SL-X-09-03.jpg deleted file mode 100644 index aa28d7a5..00000000 Binary files a/screenshots/SL-X-09-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-09-04.jpg b/screenshots/SL-X-09-04.jpg deleted file mode 100644 index 89361dff..00000000 Binary files a/screenshots/SL-X-09-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-09.jpg b/screenshots/SL-X-09.jpg deleted file mode 100644 index be64f4ef..00000000 Binary files a/screenshots/SL-X-09.jpg and /dev/null differ diff --git a/screenshots/SL-X-10-00.jpg b/screenshots/SL-X-10-00.jpg deleted file mode 100644 index 819d6c34..00000000 Binary files a/screenshots/SL-X-10-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-10-01.jpg b/screenshots/SL-X-10-01.jpg deleted file mode 100644 index 89b4efee..00000000 Binary files a/screenshots/SL-X-10-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-10-02.jpg b/screenshots/SL-X-10-02.jpg deleted file mode 100644 index 4a26ec2f..00000000 Binary files a/screenshots/SL-X-10-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-10-03.jpg b/screenshots/SL-X-10-03.jpg deleted file mode 100644 index 704b836f..00000000 Binary files a/screenshots/SL-X-10-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-10-04.jpg b/screenshots/SL-X-10-04.jpg deleted file mode 100644 index ffdd1311..00000000 Binary files a/screenshots/SL-X-10-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-10.jpg b/screenshots/SL-X-10.jpg deleted file mode 100644 index 0ce3727f..00000000 Binary files a/screenshots/SL-X-10.jpg and /dev/null differ diff --git a/screenshots/SL-X-11-00.jpg b/screenshots/SL-X-11-00.jpg deleted file mode 100644 index 8298c83a..00000000 Binary files a/screenshots/SL-X-11-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-11-01.jpg b/screenshots/SL-X-11-01.jpg deleted file mode 100644 index 3548d3bf..00000000 Binary files a/screenshots/SL-X-11-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-11-02.jpg b/screenshots/SL-X-11-02.jpg deleted file mode 100644 index 60f420d9..00000000 Binary files a/screenshots/SL-X-11-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-11-03.jpg b/screenshots/SL-X-11-03.jpg deleted file mode 100644 index 004d6e88..00000000 Binary files a/screenshots/SL-X-11-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-11-04.jpg b/screenshots/SL-X-11-04.jpg deleted file mode 100644 index 334a6176..00000000 Binary files a/screenshots/SL-X-11-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-11.jpg b/screenshots/SL-X-11.jpg deleted file mode 100644 index 6c7d984c..00000000 Binary files a/screenshots/SL-X-11.jpg and /dev/null differ diff --git a/screenshots/SL-X-12-00.jpg b/screenshots/SL-X-12-00.jpg deleted file mode 100644 index 1d8d5e15..00000000 Binary files a/screenshots/SL-X-12-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-12-01.jpg b/screenshots/SL-X-12-01.jpg deleted file mode 100644 index 3ecdab6e..00000000 Binary files a/screenshots/SL-X-12-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-12-02.jpg b/screenshots/SL-X-12-02.jpg deleted file mode 100644 index 9d54bebd..00000000 Binary files a/screenshots/SL-X-12-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-12-03.jpg b/screenshots/SL-X-12-03.jpg deleted file mode 100644 index 5200d650..00000000 Binary files a/screenshots/SL-X-12-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-12-04.jpg b/screenshots/SL-X-12-04.jpg deleted file mode 100644 index b514a932..00000000 Binary files a/screenshots/SL-X-12-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-12.jpg b/screenshots/SL-X-12.jpg deleted file mode 100644 index 4fe7bc10..00000000 Binary files a/screenshots/SL-X-12.jpg and /dev/null differ diff --git a/screenshots/SL-X-13-00.jpg b/screenshots/SL-X-13-00.jpg deleted file mode 100644 index 8e04266c..00000000 Binary files a/screenshots/SL-X-13-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-13-01.jpg b/screenshots/SL-X-13-01.jpg deleted file mode 100644 index 1566253d..00000000 Binary files a/screenshots/SL-X-13-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-13-02.jpg b/screenshots/SL-X-13-02.jpg deleted file mode 100644 index ed746122..00000000 Binary files a/screenshots/SL-X-13-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-13-03.jpg b/screenshots/SL-X-13-03.jpg deleted file mode 100644 index 276c7f05..00000000 Binary files a/screenshots/SL-X-13-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-13-04.jpg b/screenshots/SL-X-13-04.jpg deleted file mode 100644 index 06ec4bf9..00000000 Binary files a/screenshots/SL-X-13-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-13.jpg b/screenshots/SL-X-13.jpg deleted file mode 100644 index e66ce961..00000000 Binary files a/screenshots/SL-X-13.jpg and /dev/null differ diff --git a/screenshots/SL-X-14-00.jpg b/screenshots/SL-X-14-00.jpg deleted file mode 100644 index c059c784..00000000 Binary files a/screenshots/SL-X-14-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-14-01.jpg b/screenshots/SL-X-14-01.jpg deleted file mode 100644 index fe799234..00000000 Binary files a/screenshots/SL-X-14-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-14-02.jpg b/screenshots/SL-X-14-02.jpg deleted file mode 100644 index cbf1166a..00000000 Binary files a/screenshots/SL-X-14-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-14-03.jpg b/screenshots/SL-X-14-03.jpg deleted file mode 100644 index bba331b8..00000000 Binary files a/screenshots/SL-X-14-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-14-04.jpg b/screenshots/SL-X-14-04.jpg deleted file mode 100644 index 0bd56f04..00000000 Binary files a/screenshots/SL-X-14-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-14.jpg b/screenshots/SL-X-14.jpg deleted file mode 100644 index d514d624..00000000 Binary files a/screenshots/SL-X-14.jpg and /dev/null differ diff --git a/screenshots/SL-X-15-00.jpg b/screenshots/SL-X-15-00.jpg deleted file mode 100644 index 22f8dc80..00000000 Binary files a/screenshots/SL-X-15-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-15-01.jpg b/screenshots/SL-X-15-01.jpg deleted file mode 100644 index 5ac33291..00000000 Binary files a/screenshots/SL-X-15-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-15-02.jpg b/screenshots/SL-X-15-02.jpg deleted file mode 100644 index 9d8da050..00000000 Binary files a/screenshots/SL-X-15-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-15-03.jpg b/screenshots/SL-X-15-03.jpg deleted file mode 100644 index fc8e5020..00000000 Binary files a/screenshots/SL-X-15-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-15-04.jpg b/screenshots/SL-X-15-04.jpg deleted file mode 100644 index fc7b69bf..00000000 Binary files a/screenshots/SL-X-15-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-15.jpg b/screenshots/SL-X-15.jpg deleted file mode 100644 index ce0f37cd..00000000 Binary files a/screenshots/SL-X-15.jpg and /dev/null differ diff --git a/screenshots/SL-X-16-00.jpg b/screenshots/SL-X-16-00.jpg deleted file mode 100644 index 8b8decfb..00000000 Binary files a/screenshots/SL-X-16-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-16-01.jpg b/screenshots/SL-X-16-01.jpg deleted file mode 100644 index dcc86f33..00000000 Binary files a/screenshots/SL-X-16-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-16-02.jpg b/screenshots/SL-X-16-02.jpg deleted file mode 100644 index d573eae7..00000000 Binary files a/screenshots/SL-X-16-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-16-03.jpg b/screenshots/SL-X-16-03.jpg deleted file mode 100644 index dae790b1..00000000 Binary files a/screenshots/SL-X-16-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-16-04.jpg b/screenshots/SL-X-16-04.jpg deleted file mode 100644 index f0a98196..00000000 Binary files a/screenshots/SL-X-16-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-16.jpg b/screenshots/SL-X-16.jpg deleted file mode 100644 index dfdf42e6..00000000 Binary files a/screenshots/SL-X-16.jpg and /dev/null differ diff --git a/screenshots/SL-X-17-00.jpg b/screenshots/SL-X-17-00.jpg deleted file mode 100644 index fe166d7a..00000000 Binary files a/screenshots/SL-X-17-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-17-01.jpg b/screenshots/SL-X-17-01.jpg deleted file mode 100644 index ee8ae1b4..00000000 Binary files a/screenshots/SL-X-17-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-17-02.jpg b/screenshots/SL-X-17-02.jpg deleted file mode 100644 index ea6c2bc5..00000000 Binary files a/screenshots/SL-X-17-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-17-03.jpg b/screenshots/SL-X-17-03.jpg deleted file mode 100644 index 83d748ae..00000000 Binary files a/screenshots/SL-X-17-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-17-04.jpg b/screenshots/SL-X-17-04.jpg deleted file mode 100644 index 529e287e..00000000 Binary files a/screenshots/SL-X-17-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-17.jpg b/screenshots/SL-X-17.jpg deleted file mode 100644 index fde3a6ba..00000000 Binary files a/screenshots/SL-X-17.jpg and /dev/null differ diff --git a/screenshots/SL-X-18-00.jpg b/screenshots/SL-X-18-00.jpg deleted file mode 100644 index 8793829a..00000000 Binary files a/screenshots/SL-X-18-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-18-01.jpg b/screenshots/SL-X-18-01.jpg deleted file mode 100644 index d3c8edb1..00000000 Binary files a/screenshots/SL-X-18-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-18-02.jpg b/screenshots/SL-X-18-02.jpg deleted file mode 100644 index d97928ff..00000000 Binary files a/screenshots/SL-X-18-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-18-03.jpg b/screenshots/SL-X-18-03.jpg deleted file mode 100644 index c04188e5..00000000 Binary files a/screenshots/SL-X-18-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-18-04.jpg b/screenshots/SL-X-18-04.jpg deleted file mode 100644 index c5139d9b..00000000 Binary files a/screenshots/SL-X-18-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-18.jpg b/screenshots/SL-X-18.jpg deleted file mode 100644 index 687faaae..00000000 Binary files a/screenshots/SL-X-18.jpg and /dev/null differ diff --git a/screenshots/SL-X-19-00.jpg b/screenshots/SL-X-19-00.jpg deleted file mode 100644 index 85b4169b..00000000 Binary files a/screenshots/SL-X-19-00.jpg and /dev/null differ diff --git a/screenshots/SL-X-19-01.jpg b/screenshots/SL-X-19-01.jpg deleted file mode 100644 index 1c755f6b..00000000 Binary files a/screenshots/SL-X-19-01.jpg and /dev/null differ diff --git a/screenshots/SL-X-19-02.jpg b/screenshots/SL-X-19-02.jpg deleted file mode 100644 index 71dfffd3..00000000 Binary files a/screenshots/SL-X-19-02.jpg and /dev/null differ diff --git a/screenshots/SL-X-19-03.jpg b/screenshots/SL-X-19-03.jpg deleted file mode 100644 index 12714f84..00000000 Binary files a/screenshots/SL-X-19-03.jpg and /dev/null differ diff --git a/screenshots/SL-X-19-04.jpg b/screenshots/SL-X-19-04.jpg deleted file mode 100644 index f0939671..00000000 Binary files a/screenshots/SL-X-19-04.jpg and /dev/null differ diff --git a/screenshots/SL-X-19.jpg b/screenshots/SL-X-19.jpg deleted file mode 100644 index 66331096..00000000 Binary files a/screenshots/SL-X-19.jpg and /dev/null differ diff --git a/screenshots/SS-A-00.jpg b/screenshots/SS-A-00.jpg deleted file mode 100644 index 1216e3b6..00000000 Binary files a/screenshots/SS-A-00.jpg and /dev/null differ diff --git a/screenshots/SS-A-01.jpg b/screenshots/SS-A-01.jpg deleted file mode 100644 index 106b9f6f..00000000 Binary files a/screenshots/SS-A-01.jpg and /dev/null differ diff --git a/screenshots/SS-A-02.jpg b/screenshots/SS-A-02.jpg deleted file mode 100644 index 0cd9b0b4..00000000 Binary files a/screenshots/SS-A-02.jpg and /dev/null differ diff --git a/screenshots/SS-A-03.jpg b/screenshots/SS-A-03.jpg deleted file mode 100644 index ae875bd2..00000000 Binary files a/screenshots/SS-A-03.jpg and /dev/null differ diff --git a/screenshots/SS-A-04.jpg b/screenshots/SS-A-04.jpg deleted file mode 100644 index 467cb564..00000000 Binary files a/screenshots/SS-A-04.jpg and /dev/null differ diff --git a/screenshots/SS-A-05.jpg b/screenshots/SS-A-05.jpg deleted file mode 100644 index 9124b5fa..00000000 Binary files a/screenshots/SS-A-05.jpg and /dev/null differ diff --git a/screenshots/SS-A-06.jpg b/screenshots/SS-A-06.jpg deleted file mode 100644 index 7b5d6472..00000000 Binary files a/screenshots/SS-A-06.jpg and /dev/null differ diff --git a/screenshots/SS-A-07.jpg b/screenshots/SS-A-07.jpg deleted file mode 100644 index 5e0e88c4..00000000 Binary files a/screenshots/SS-A-07.jpg and /dev/null differ diff --git a/screenshots/SS-A-08.jpg b/screenshots/SS-A-08.jpg deleted file mode 100644 index a552c4f4..00000000 Binary files a/screenshots/SS-A-08.jpg and /dev/null differ diff --git a/screenshots/SS-A-09.jpg b/screenshots/SS-A-09.jpg deleted file mode 100644 index 27b4191e..00000000 Binary files a/screenshots/SS-A-09.jpg and /dev/null differ diff --git a/screenshots/SS-A-10.jpg b/screenshots/SS-A-10.jpg deleted file mode 100644 index 77e459fe..00000000 Binary files a/screenshots/SS-A-10.jpg and /dev/null differ diff --git a/screenshots/SS-A-11.jpg b/screenshots/SS-A-11.jpg deleted file mode 100644 index 97eea543..00000000 Binary files a/screenshots/SS-A-11.jpg and /dev/null differ diff --git a/screenshots/SS-A-12.jpg b/screenshots/SS-A-12.jpg deleted file mode 100644 index 67ce9aab..00000000 Binary files a/screenshots/SS-A-12.jpg and /dev/null differ diff --git a/screenshots/SS-A-13.jpg b/screenshots/SS-A-13.jpg deleted file mode 100644 index c0ce7e6f..00000000 Binary files a/screenshots/SS-A-13.jpg and /dev/null differ diff --git a/screenshots/SS-A-14.jpg b/screenshots/SS-A-14.jpg deleted file mode 100644 index fb8e4d82..00000000 Binary files a/screenshots/SS-A-14.jpg and /dev/null differ diff --git a/screenshots/SS-A-15.jpg b/screenshots/SS-A-15.jpg deleted file mode 100644 index b25d6724..00000000 Binary files a/screenshots/SS-A-15.jpg and /dev/null differ diff --git a/screenshots/SS-A-16.jpg b/screenshots/SS-A-16.jpg deleted file mode 100644 index 2796c657..00000000 Binary files a/screenshots/SS-A-16.jpg and /dev/null differ diff --git a/screenshots/SS-A-17.jpg b/screenshots/SS-A-17.jpg deleted file mode 100644 index a2313dcf..00000000 Binary files a/screenshots/SS-A-17.jpg and /dev/null differ diff --git a/screenshots/SS-A-18.jpg b/screenshots/SS-A-18.jpg deleted file mode 100644 index b7615680..00000000 Binary files a/screenshots/SS-A-18.jpg and /dev/null differ diff --git a/screenshots/SS-A-19.jpg b/screenshots/SS-A-19.jpg deleted file mode 100644 index c0fd6ce3..00000000 Binary files a/screenshots/SS-A-19.jpg and /dev/null differ diff --git a/screenshots/SS-A-20.jpg b/screenshots/SS-A-20.jpg deleted file mode 100644 index 048a5f78..00000000 Binary files a/screenshots/SS-A-20.jpg and /dev/null differ diff --git a/screenshots/SS-A-21.jpg b/screenshots/SS-A-21.jpg deleted file mode 100644 index 8b6453f0..00000000 Binary files a/screenshots/SS-A-21.jpg and /dev/null differ diff --git a/screenshots/SS-A-22.jpg b/screenshots/SS-A-22.jpg deleted file mode 100644 index 500f23f1..00000000 Binary files a/screenshots/SS-A-22.jpg and /dev/null differ diff --git a/screenshots/SS-A-23.jpg b/screenshots/SS-A-23.jpg deleted file mode 100644 index 3a541c13..00000000 Binary files a/screenshots/SS-A-23.jpg and /dev/null differ diff --git a/screenshots/SS-B-00.jpg b/screenshots/SS-B-00.jpg deleted file mode 100644 index bd9e4353..00000000 Binary files a/screenshots/SS-B-00.jpg and /dev/null differ diff --git a/screenshots/SS-B-01.jpg b/screenshots/SS-B-01.jpg deleted file mode 100644 index 8a7b33c5..00000000 Binary files a/screenshots/SS-B-01.jpg and /dev/null differ diff --git a/screenshots/SS-B-02.jpg b/screenshots/SS-B-02.jpg deleted file mode 100644 index 8b5e457a..00000000 Binary files a/screenshots/SS-B-02.jpg and /dev/null differ diff --git a/screenshots/SS-B-03.jpg b/screenshots/SS-B-03.jpg deleted file mode 100644 index acea3d5d..00000000 Binary files a/screenshots/SS-B-03.jpg and /dev/null differ diff --git a/screenshots/SS-B-04.jpg b/screenshots/SS-B-04.jpg deleted file mode 100644 index 9ca08397..00000000 Binary files a/screenshots/SS-B-04.jpg and /dev/null differ diff --git a/screenshots/SS-B-05.jpg b/screenshots/SS-B-05.jpg deleted file mode 100644 index da47b1c8..00000000 Binary files a/screenshots/SS-B-05.jpg and /dev/null differ diff --git a/screenshots/SS-B-06.jpg b/screenshots/SS-B-06.jpg deleted file mode 100644 index 34feba05..00000000 Binary files a/screenshots/SS-B-06.jpg and /dev/null differ diff --git a/screenshots/SS-B-07.jpg b/screenshots/SS-B-07.jpg deleted file mode 100644 index 7688019c..00000000 Binary files a/screenshots/SS-B-07.jpg and /dev/null differ diff --git a/screenshots/SS-B-08.jpg b/screenshots/SS-B-08.jpg deleted file mode 100644 index 662d23ef..00000000 Binary files a/screenshots/SS-B-08.jpg and /dev/null differ diff --git a/screenshots/SS-B-09.jpg b/screenshots/SS-B-09.jpg deleted file mode 100644 index 2c638c72..00000000 Binary files a/screenshots/SS-B-09.jpg and /dev/null differ diff --git a/screenshots/SS-B-10.jpg b/screenshots/SS-B-10.jpg deleted file mode 100644 index 827bb41a..00000000 Binary files a/screenshots/SS-B-10.jpg and /dev/null differ diff --git a/screenshots/SS-B-11.jpg b/screenshots/SS-B-11.jpg deleted file mode 100644 index c734e734..00000000 Binary files a/screenshots/SS-B-11.jpg and /dev/null differ diff --git a/screenshots/SS-B-12.jpg b/screenshots/SS-B-12.jpg deleted file mode 100644 index 03f49990..00000000 Binary files a/screenshots/SS-B-12.jpg and /dev/null differ diff --git a/screenshots/SS-B-13.jpg b/screenshots/SS-B-13.jpg deleted file mode 100644 index 30c561b6..00000000 Binary files a/screenshots/SS-B-13.jpg and /dev/null differ diff --git a/screenshots/SS-B-14.jpg b/screenshots/SS-B-14.jpg deleted file mode 100644 index 2d23f20d..00000000 Binary files a/screenshots/SS-B-14.jpg and /dev/null differ diff --git a/screenshots/SS-B-15.jpg b/screenshots/SS-B-15.jpg deleted file mode 100644 index 72e1afb6..00000000 Binary files a/screenshots/SS-B-15.jpg and /dev/null differ diff --git a/screenshots/SS-B-16.jpg b/screenshots/SS-B-16.jpg deleted file mode 100644 index 2d64e254..00000000 Binary files a/screenshots/SS-B-16.jpg and /dev/null differ diff --git a/screenshots/SS-B-17.jpg b/screenshots/SS-B-17.jpg deleted file mode 100644 index 2de240d1..00000000 Binary files a/screenshots/SS-B-17.jpg and /dev/null differ diff --git a/screenshots/SS-B-18.jpg b/screenshots/SS-B-18.jpg deleted file mode 100644 index 0b47d5f7..00000000 Binary files a/screenshots/SS-B-18.jpg and /dev/null differ diff --git a/screenshots/SS-B-19.jpg b/screenshots/SS-B-19.jpg deleted file mode 100644 index f4bd6986..00000000 Binary files a/screenshots/SS-B-19.jpg and /dev/null differ diff --git a/screenshots/SS-B-20.jpg b/screenshots/SS-B-20.jpg deleted file mode 100644 index 603ebec6..00000000 Binary files a/screenshots/SS-B-20.jpg and /dev/null differ diff --git a/screenshots/SS-B-21.jpg b/screenshots/SS-B-21.jpg deleted file mode 100644 index 102e0d43..00000000 Binary files a/screenshots/SS-B-21.jpg and /dev/null differ diff --git a/screenshots/SS-B-22.jpg b/screenshots/SS-B-22.jpg deleted file mode 100644 index e47cc9cd..00000000 Binary files a/screenshots/SS-B-22.jpg and /dev/null differ diff --git a/screenshots/SS-B-23.jpg b/screenshots/SS-B-23.jpg deleted file mode 100644 index 766e96c5..00000000 Binary files a/screenshots/SS-B-23.jpg and /dev/null differ diff --git a/screenshots/SS-C-00.jpg b/screenshots/SS-C-00.jpg deleted file mode 100644 index 15c580ce..00000000 Binary files a/screenshots/SS-C-00.jpg and /dev/null differ diff --git a/screenshots/SS-C-01.jpg b/screenshots/SS-C-01.jpg deleted file mode 100644 index d2127f90..00000000 Binary files a/screenshots/SS-C-01.jpg and /dev/null differ diff --git a/screenshots/SS-C-02.jpg b/screenshots/SS-C-02.jpg deleted file mode 100644 index 4669d363..00000000 Binary files a/screenshots/SS-C-02.jpg and /dev/null differ diff --git a/screenshots/SS-C-03.jpg b/screenshots/SS-C-03.jpg deleted file mode 100644 index 88d94fc5..00000000 Binary files a/screenshots/SS-C-03.jpg and /dev/null differ diff --git a/screenshots/SS-C-04.jpg b/screenshots/SS-C-04.jpg deleted file mode 100644 index 9abd0bf0..00000000 Binary files a/screenshots/SS-C-04.jpg and /dev/null differ diff --git a/screenshots/SS-C-05.jpg b/screenshots/SS-C-05.jpg deleted file mode 100644 index 19569f19..00000000 Binary files a/screenshots/SS-C-05.jpg and /dev/null differ diff --git a/screenshots/SS-C-06.jpg b/screenshots/SS-C-06.jpg deleted file mode 100644 index bf0ba2f5..00000000 Binary files a/screenshots/SS-C-06.jpg and /dev/null differ diff --git a/screenshots/SS-C-07.jpg b/screenshots/SS-C-07.jpg deleted file mode 100644 index 1719a6eb..00000000 Binary files a/screenshots/SS-C-07.jpg and /dev/null differ diff --git a/screenshots/SS-C-08.jpg b/screenshots/SS-C-08.jpg deleted file mode 100644 index d3e6a06e..00000000 Binary files a/screenshots/SS-C-08.jpg and /dev/null differ diff --git a/screenshots/SS-C-09.jpg b/screenshots/SS-C-09.jpg deleted file mode 100644 index 30cd1a58..00000000 Binary files a/screenshots/SS-C-09.jpg and /dev/null differ diff --git a/screenshots/SS-C-10.jpg b/screenshots/SS-C-10.jpg deleted file mode 100644 index 9e3e161c..00000000 Binary files a/screenshots/SS-C-10.jpg and /dev/null differ diff --git a/screenshots/SS-C-11.jpg b/screenshots/SS-C-11.jpg deleted file mode 100644 index 0e44d01e..00000000 Binary files a/screenshots/SS-C-11.jpg and /dev/null differ diff --git a/screenshots/SS-C-12.jpg b/screenshots/SS-C-12.jpg deleted file mode 100644 index 43f0c1ce..00000000 Binary files a/screenshots/SS-C-12.jpg and /dev/null differ diff --git a/screenshots/SS-C-13.jpg b/screenshots/SS-C-13.jpg deleted file mode 100644 index dbb7b304..00000000 Binary files a/screenshots/SS-C-13.jpg and /dev/null differ diff --git a/screenshots/SS-C-14.jpg b/screenshots/SS-C-14.jpg deleted file mode 100644 index dad79c3d..00000000 Binary files a/screenshots/SS-C-14.jpg and /dev/null differ diff --git a/screenshots/SS-C-15.jpg b/screenshots/SS-C-15.jpg deleted file mode 100644 index da36f9f6..00000000 Binary files a/screenshots/SS-C-15.jpg and /dev/null differ diff --git a/screenshots/SS-C-16.jpg b/screenshots/SS-C-16.jpg deleted file mode 100644 index 81ab6d9b..00000000 Binary files a/screenshots/SS-C-16.jpg and /dev/null differ diff --git a/screenshots/SS-C-17.jpg b/screenshots/SS-C-17.jpg deleted file mode 100644 index 19b3b37e..00000000 Binary files a/screenshots/SS-C-17.jpg and /dev/null differ diff --git a/screenshots/SS-C-18.jpg b/screenshots/SS-C-18.jpg deleted file mode 100644 index ef42ae8e..00000000 Binary files a/screenshots/SS-C-18.jpg and /dev/null differ diff --git a/screenshots/SS-C-19.jpg b/screenshots/SS-C-19.jpg deleted file mode 100644 index c42106f4..00000000 Binary files a/screenshots/SS-C-19.jpg and /dev/null differ diff --git a/screenshots/SS-C-20.jpg b/screenshots/SS-C-20.jpg deleted file mode 100644 index 9985c414..00000000 Binary files a/screenshots/SS-C-20.jpg and /dev/null differ diff --git a/screenshots/SS-C-21.jpg b/screenshots/SS-C-21.jpg deleted file mode 100644 index 4d1126ec..00000000 Binary files a/screenshots/SS-C-21.jpg and /dev/null differ diff --git a/screenshots/SS-C-22.jpg b/screenshots/SS-C-22.jpg deleted file mode 100644 index 4041cb70..00000000 Binary files a/screenshots/SS-C-22.jpg and /dev/null differ diff --git a/screenshots/SS-C-23.jpg b/screenshots/SS-C-23.jpg deleted file mode 100644 index 6ff28a5c..00000000 Binary files a/screenshots/SS-C-23.jpg and /dev/null differ diff --git a/screenshots/SS-D-00.jpg b/screenshots/SS-D-00.jpg deleted file mode 100644 index 8864bffd..00000000 Binary files a/screenshots/SS-D-00.jpg and /dev/null differ diff --git a/screenshots/SS-D-01.jpg b/screenshots/SS-D-01.jpg deleted file mode 100644 index 2076f4dd..00000000 Binary files a/screenshots/SS-D-01.jpg and /dev/null differ diff --git a/screenshots/SS-D-02.jpg b/screenshots/SS-D-02.jpg deleted file mode 100644 index 31dcbb43..00000000 Binary files a/screenshots/SS-D-02.jpg and /dev/null differ diff --git a/screenshots/SS-D-03.jpg b/screenshots/SS-D-03.jpg deleted file mode 100644 index f16ae34b..00000000 Binary files a/screenshots/SS-D-03.jpg and /dev/null differ diff --git a/screenshots/SS-D-04.jpg b/screenshots/SS-D-04.jpg deleted file mode 100644 index 61e73e03..00000000 Binary files a/screenshots/SS-D-04.jpg and /dev/null differ diff --git a/screenshots/SS-D-05.jpg b/screenshots/SS-D-05.jpg deleted file mode 100644 index 4d8701bb..00000000 Binary files a/screenshots/SS-D-05.jpg and /dev/null differ diff --git a/screenshots/SS-D-06.jpg b/screenshots/SS-D-06.jpg deleted file mode 100644 index 9819a63c..00000000 Binary files a/screenshots/SS-D-06.jpg and /dev/null differ diff --git a/screenshots/SS-D-07.jpg b/screenshots/SS-D-07.jpg deleted file mode 100644 index 38c5ac56..00000000 Binary files a/screenshots/SS-D-07.jpg and /dev/null differ diff --git a/screenshots/SS-D-08.jpg b/screenshots/SS-D-08.jpg deleted file mode 100644 index a294994e..00000000 Binary files a/screenshots/SS-D-08.jpg and /dev/null differ diff --git a/screenshots/SS-D-09.jpg b/screenshots/SS-D-09.jpg deleted file mode 100644 index 992eb74b..00000000 Binary files a/screenshots/SS-D-09.jpg and /dev/null differ diff --git a/screenshots/SS-D-10.jpg b/screenshots/SS-D-10.jpg deleted file mode 100644 index 2af984f0..00000000 Binary files a/screenshots/SS-D-10.jpg and /dev/null differ diff --git a/screenshots/SS-D-11.jpg b/screenshots/SS-D-11.jpg deleted file mode 100644 index 9dd670cc..00000000 Binary files a/screenshots/SS-D-11.jpg and /dev/null differ diff --git a/screenshots/SS-D-12.jpg b/screenshots/SS-D-12.jpg deleted file mode 100644 index 6364c3f6..00000000 Binary files a/screenshots/SS-D-12.jpg and /dev/null differ diff --git a/screenshots/SS-D-13.jpg b/screenshots/SS-D-13.jpg deleted file mode 100644 index 45f8141d..00000000 Binary files a/screenshots/SS-D-13.jpg and /dev/null differ diff --git a/screenshots/SS-D-14.jpg b/screenshots/SS-D-14.jpg deleted file mode 100644 index a17b6858..00000000 Binary files a/screenshots/SS-D-14.jpg and /dev/null differ diff --git a/screenshots/SS-D-15.jpg b/screenshots/SS-D-15.jpg deleted file mode 100644 index a870d0f2..00000000 Binary files a/screenshots/SS-D-15.jpg and /dev/null differ diff --git a/screenshots/SS-D-16.jpg b/screenshots/SS-D-16.jpg deleted file mode 100644 index 90c3e785..00000000 Binary files a/screenshots/SS-D-16.jpg and /dev/null differ diff --git a/screenshots/SS-D-17.jpg b/screenshots/SS-D-17.jpg deleted file mode 100644 index 63a71bb8..00000000 Binary files a/screenshots/SS-D-17.jpg and /dev/null differ diff --git a/screenshots/SS-D-18.jpg b/screenshots/SS-D-18.jpg deleted file mode 100644 index efc2449b..00000000 Binary files a/screenshots/SS-D-18.jpg and /dev/null differ diff --git a/screenshots/SS-D-19.jpg b/screenshots/SS-D-19.jpg deleted file mode 100644 index 2c96d5fa..00000000 Binary files a/screenshots/SS-D-19.jpg and /dev/null differ diff --git a/screenshots/SS-D-20.jpg b/screenshots/SS-D-20.jpg deleted file mode 100644 index 2ba0ee01..00000000 Binary files a/screenshots/SS-D-20.jpg and /dev/null differ diff --git a/screenshots/SS-D-21.jpg b/screenshots/SS-D-21.jpg deleted file mode 100644 index 7786f60f..00000000 Binary files a/screenshots/SS-D-21.jpg and /dev/null differ diff --git a/screenshots/SS-D-22.jpg b/screenshots/SS-D-22.jpg deleted file mode 100644 index c022af44..00000000 Binary files a/screenshots/SS-D-22.jpg and /dev/null differ diff --git a/screenshots/SS-D-23.jpg b/screenshots/SS-D-23.jpg deleted file mode 100644 index 69926dc1..00000000 Binary files a/screenshots/SS-D-23.jpg and /dev/null differ diff --git a/screenshots/SS-E-00.jpg b/screenshots/SS-E-00.jpg deleted file mode 100644 index bd2930d1..00000000 Binary files a/screenshots/SS-E-00.jpg and /dev/null differ diff --git a/screenshots/SS-E-01.jpg b/screenshots/SS-E-01.jpg deleted file mode 100644 index 6dbd2122..00000000 Binary files a/screenshots/SS-E-01.jpg and /dev/null differ diff --git a/screenshots/SS-E-02.jpg b/screenshots/SS-E-02.jpg deleted file mode 100644 index 54462647..00000000 Binary files a/screenshots/SS-E-02.jpg and /dev/null differ diff --git a/screenshots/SS-E-03.jpg b/screenshots/SS-E-03.jpg deleted file mode 100644 index e93426d6..00000000 Binary files a/screenshots/SS-E-03.jpg and /dev/null differ diff --git a/screenshots/SS-E-04.jpg b/screenshots/SS-E-04.jpg deleted file mode 100644 index 25094292..00000000 Binary files a/screenshots/SS-E-04.jpg and /dev/null differ diff --git a/screenshots/SS-E-05.jpg b/screenshots/SS-E-05.jpg deleted file mode 100644 index e07b08bf..00000000 Binary files a/screenshots/SS-E-05.jpg and /dev/null differ diff --git a/screenshots/SS-E-06.jpg b/screenshots/SS-E-06.jpg deleted file mode 100644 index 98df7627..00000000 Binary files a/screenshots/SS-E-06.jpg and /dev/null differ diff --git a/screenshots/SS-E-07.jpg b/screenshots/SS-E-07.jpg deleted file mode 100644 index 6165efed..00000000 Binary files a/screenshots/SS-E-07.jpg and /dev/null differ diff --git a/screenshots/SS-E-08.jpg b/screenshots/SS-E-08.jpg deleted file mode 100644 index 5a460b10..00000000 Binary files a/screenshots/SS-E-08.jpg and /dev/null differ diff --git a/screenshots/SS-E-09.jpg b/screenshots/SS-E-09.jpg deleted file mode 100644 index aacabc72..00000000 Binary files a/screenshots/SS-E-09.jpg and /dev/null differ diff --git a/screenshots/SS-E-10.jpg b/screenshots/SS-E-10.jpg deleted file mode 100644 index 4335c20f..00000000 Binary files a/screenshots/SS-E-10.jpg and /dev/null differ diff --git a/screenshots/SS-E-11.jpg b/screenshots/SS-E-11.jpg deleted file mode 100644 index 88f91ef5..00000000 Binary files a/screenshots/SS-E-11.jpg and /dev/null differ diff --git a/screenshots/SS-E-12.jpg b/screenshots/SS-E-12.jpg deleted file mode 100644 index 9a151b98..00000000 Binary files a/screenshots/SS-E-12.jpg and /dev/null differ diff --git a/screenshots/SS-E-13.jpg b/screenshots/SS-E-13.jpg deleted file mode 100644 index 68b59113..00000000 Binary files a/screenshots/SS-E-13.jpg and /dev/null differ diff --git a/screenshots/SS-E-14.jpg b/screenshots/SS-E-14.jpg deleted file mode 100644 index 9c53b676..00000000 Binary files a/screenshots/SS-E-14.jpg and /dev/null differ diff --git a/screenshots/SS-E-15.jpg b/screenshots/SS-E-15.jpg deleted file mode 100644 index 5ffb2602..00000000 Binary files a/screenshots/SS-E-15.jpg and /dev/null differ diff --git a/screenshots/SS-E-16.jpg b/screenshots/SS-E-16.jpg deleted file mode 100644 index 346e063b..00000000 Binary files a/screenshots/SS-E-16.jpg and /dev/null differ diff --git a/screenshots/SS-E-17.jpg b/screenshots/SS-E-17.jpg deleted file mode 100644 index 9d6c3e8f..00000000 Binary files a/screenshots/SS-E-17.jpg and /dev/null differ diff --git a/screenshots/SS-E-18.jpg b/screenshots/SS-E-18.jpg deleted file mode 100644 index c78e70af..00000000 Binary files a/screenshots/SS-E-18.jpg and /dev/null differ diff --git a/screenshots/SS-E-19.jpg b/screenshots/SS-E-19.jpg deleted file mode 100644 index 3ad34d0c..00000000 Binary files a/screenshots/SS-E-19.jpg and /dev/null differ diff --git a/screenshots/SS-E-20.jpg b/screenshots/SS-E-20.jpg deleted file mode 100644 index f73c9582..00000000 Binary files a/screenshots/SS-E-20.jpg and /dev/null differ diff --git a/screenshots/SS-E-21.jpg b/screenshots/SS-E-21.jpg deleted file mode 100644 index 0e5891f5..00000000 Binary files a/screenshots/SS-E-21.jpg and /dev/null differ diff --git a/screenshots/SS-E-22.jpg b/screenshots/SS-E-22.jpg deleted file mode 100644 index 68b62b51..00000000 Binary files a/screenshots/SS-E-22.jpg and /dev/null differ diff --git a/screenshots/SS-E-23.jpg b/screenshots/SS-E-23.jpg deleted file mode 100644 index 32d6e6aa..00000000 Binary files a/screenshots/SS-E-23.jpg and /dev/null differ diff --git a/screenshots/SS2-A-00.jpg b/screenshots/SS2-A-00.jpg deleted file mode 100644 index 91b63298..00000000 Binary files a/screenshots/SS2-A-00.jpg and /dev/null differ diff --git a/screenshots/SS2-A-01.jpg b/screenshots/SS2-A-01.jpg deleted file mode 100644 index 36e10204..00000000 Binary files a/screenshots/SS2-A-01.jpg and /dev/null differ diff --git a/screenshots/SS2-A-02.jpg b/screenshots/SS2-A-02.jpg deleted file mode 100644 index 078545a5..00000000 Binary files a/screenshots/SS2-A-02.jpg and /dev/null differ diff --git a/screenshots/SS2-A-03.jpg b/screenshots/SS2-A-03.jpg deleted file mode 100644 index 4027c259..00000000 Binary files a/screenshots/SS2-A-03.jpg and /dev/null differ diff --git a/screenshots/SS2-A-04.jpg b/screenshots/SS2-A-04.jpg deleted file mode 100644 index 5e710df3..00000000 Binary files a/screenshots/SS2-A-04.jpg and /dev/null differ diff --git a/screenshots/SS2-A-05.jpg b/screenshots/SS2-A-05.jpg deleted file mode 100644 index f95ab2da..00000000 Binary files a/screenshots/SS2-A-05.jpg and /dev/null differ diff --git a/screenshots/SS2-A-06.jpg b/screenshots/SS2-A-06.jpg deleted file mode 100644 index 633142c4..00000000 Binary files a/screenshots/SS2-A-06.jpg and /dev/null differ diff --git a/screenshots/SS2-A-07.jpg b/screenshots/SS2-A-07.jpg deleted file mode 100644 index 59873cdc..00000000 Binary files a/screenshots/SS2-A-07.jpg and /dev/null differ diff --git a/screenshots/SS2-A-08.jpg b/screenshots/SS2-A-08.jpg deleted file mode 100644 index 39c1962d..00000000 Binary files a/screenshots/SS2-A-08.jpg and /dev/null differ diff --git a/screenshots/SS2-A-09.jpg b/screenshots/SS2-A-09.jpg deleted file mode 100644 index 89e52878..00000000 Binary files a/screenshots/SS2-A-09.jpg and /dev/null differ diff --git a/screenshots/SS2-A-10.jpg b/screenshots/SS2-A-10.jpg deleted file mode 100644 index 87db9dcf..00000000 Binary files a/screenshots/SS2-A-10.jpg and /dev/null differ diff --git a/screenshots/SS2-A-11.jpg b/screenshots/SS2-A-11.jpg deleted file mode 100644 index af5d0254..00000000 Binary files a/screenshots/SS2-A-11.jpg and /dev/null differ diff --git a/screenshots/SS2-A-12.jpg b/screenshots/SS2-A-12.jpg deleted file mode 100644 index 691d6067..00000000 Binary files a/screenshots/SS2-A-12.jpg and /dev/null differ diff --git a/screenshots/SS2-A-13.jpg b/screenshots/SS2-A-13.jpg deleted file mode 100644 index 0ed68921..00000000 Binary files a/screenshots/SS2-A-13.jpg and /dev/null differ diff --git a/screenshots/SS2-A-14.jpg b/screenshots/SS2-A-14.jpg deleted file mode 100644 index b5e7c42d..00000000 Binary files a/screenshots/SS2-A-14.jpg and /dev/null differ diff --git a/screenshots/SS2-A-15.jpg b/screenshots/SS2-A-15.jpg deleted file mode 100644 index 67144dc2..00000000 Binary files a/screenshots/SS2-A-15.jpg and /dev/null differ diff --git a/screenshots/SS2-A-16.jpg b/screenshots/SS2-A-16.jpg deleted file mode 100644 index 1b1f78b2..00000000 Binary files a/screenshots/SS2-A-16.jpg and /dev/null differ diff --git a/screenshots/SS2-A-17.jpg b/screenshots/SS2-A-17.jpg deleted file mode 100644 index 5bd61c21..00000000 Binary files a/screenshots/SS2-A-17.jpg and /dev/null differ diff --git a/screenshots/SS2-A-18.jpg b/screenshots/SS2-A-18.jpg deleted file mode 100644 index 73dd7a4e..00000000 Binary files a/screenshots/SS2-A-18.jpg and /dev/null differ diff --git a/screenshots/SS2-A-19.jpg b/screenshots/SS2-A-19.jpg deleted file mode 100644 index 4fb142a0..00000000 Binary files a/screenshots/SS2-A-19.jpg and /dev/null differ diff --git a/screenshots/SS2-A-20.jpg b/screenshots/SS2-A-20.jpg deleted file mode 100644 index bc68a6a0..00000000 Binary files a/screenshots/SS2-A-20.jpg and /dev/null differ diff --git a/screenshots/SS2-A-21.jpg b/screenshots/SS2-A-21.jpg deleted file mode 100644 index b73bd830..00000000 Binary files a/screenshots/SS2-A-21.jpg and /dev/null differ diff --git a/screenshots/SS2-A-22.jpg b/screenshots/SS2-A-22.jpg deleted file mode 100644 index cb48ebec..00000000 Binary files a/screenshots/SS2-A-22.jpg and /dev/null differ diff --git a/screenshots/SS2-A-23.jpg b/screenshots/SS2-A-23.jpg deleted file mode 100644 index b2d678b4..00000000 Binary files a/screenshots/SS2-A-23.jpg and /dev/null differ diff --git a/screenshots/SS2-B-00.jpg b/screenshots/SS2-B-00.jpg deleted file mode 100644 index 5910ddec..00000000 Binary files a/screenshots/SS2-B-00.jpg and /dev/null differ diff --git a/screenshots/SS2-B-01.jpg b/screenshots/SS2-B-01.jpg deleted file mode 100644 index 5953f882..00000000 Binary files a/screenshots/SS2-B-01.jpg and /dev/null differ diff --git a/screenshots/SS2-B-02.jpg b/screenshots/SS2-B-02.jpg deleted file mode 100644 index c5f64ab4..00000000 Binary files a/screenshots/SS2-B-02.jpg and /dev/null differ diff --git a/screenshots/SS2-B-03.jpg b/screenshots/SS2-B-03.jpg deleted file mode 100644 index 6750e4c7..00000000 Binary files a/screenshots/SS2-B-03.jpg and /dev/null differ diff --git a/screenshots/SS2-B-04.jpg b/screenshots/SS2-B-04.jpg deleted file mode 100644 index 9d5cdd7c..00000000 Binary files a/screenshots/SS2-B-04.jpg and /dev/null differ diff --git a/screenshots/SS2-B-05.jpg b/screenshots/SS2-B-05.jpg deleted file mode 100644 index e554f6d5..00000000 Binary files a/screenshots/SS2-B-05.jpg and /dev/null differ diff --git a/screenshots/SS2-B-06.jpg b/screenshots/SS2-B-06.jpg deleted file mode 100644 index cd030901..00000000 Binary files a/screenshots/SS2-B-06.jpg and /dev/null differ diff --git a/screenshots/SS2-B-07.jpg b/screenshots/SS2-B-07.jpg deleted file mode 100644 index 445293fa..00000000 Binary files a/screenshots/SS2-B-07.jpg and /dev/null differ diff --git a/screenshots/SS2-B-08.jpg b/screenshots/SS2-B-08.jpg deleted file mode 100644 index cc33275d..00000000 Binary files a/screenshots/SS2-B-08.jpg and /dev/null differ diff --git a/screenshots/SS2-B-09.jpg b/screenshots/SS2-B-09.jpg deleted file mode 100644 index e2d02184..00000000 Binary files a/screenshots/SS2-B-09.jpg and /dev/null differ diff --git a/screenshots/SS2-B-10.jpg b/screenshots/SS2-B-10.jpg deleted file mode 100644 index 463caae5..00000000 Binary files a/screenshots/SS2-B-10.jpg and /dev/null differ diff --git a/screenshots/SS2-B-11.jpg b/screenshots/SS2-B-11.jpg deleted file mode 100644 index 7095c45b..00000000 Binary files a/screenshots/SS2-B-11.jpg and /dev/null differ diff --git a/screenshots/SS2-B-12.jpg b/screenshots/SS2-B-12.jpg deleted file mode 100644 index 0b6125bd..00000000 Binary files a/screenshots/SS2-B-12.jpg and /dev/null differ diff --git a/screenshots/SS2-B-13.jpg b/screenshots/SS2-B-13.jpg deleted file mode 100644 index 4dce3c07..00000000 Binary files a/screenshots/SS2-B-13.jpg and /dev/null differ diff --git a/screenshots/SS2-B-14.jpg b/screenshots/SS2-B-14.jpg deleted file mode 100644 index 98a4439f..00000000 Binary files a/screenshots/SS2-B-14.jpg and /dev/null differ diff --git a/screenshots/SS2-B-15.jpg b/screenshots/SS2-B-15.jpg deleted file mode 100644 index cd0101cf..00000000 Binary files a/screenshots/SS2-B-15.jpg and /dev/null differ diff --git a/screenshots/SS2-B-16.jpg b/screenshots/SS2-B-16.jpg deleted file mode 100644 index 6f62cc94..00000000 Binary files a/screenshots/SS2-B-16.jpg and /dev/null differ diff --git a/screenshots/SS2-B-17.jpg b/screenshots/SS2-B-17.jpg deleted file mode 100644 index 27bf455b..00000000 Binary files a/screenshots/SS2-B-17.jpg and /dev/null differ diff --git a/screenshots/SS2-B-18.jpg b/screenshots/SS2-B-18.jpg deleted file mode 100644 index 44625635..00000000 Binary files a/screenshots/SS2-B-18.jpg and /dev/null differ diff --git a/screenshots/SS2-B-19.jpg b/screenshots/SS2-B-19.jpg deleted file mode 100644 index 3a9ba9e6..00000000 Binary files a/screenshots/SS2-B-19.jpg and /dev/null differ diff --git a/screenshots/SS2-B-20.jpg b/screenshots/SS2-B-20.jpg deleted file mode 100644 index ca21e1ea..00000000 Binary files a/screenshots/SS2-B-20.jpg and /dev/null differ diff --git a/screenshots/SS2-B-21.jpg b/screenshots/SS2-B-21.jpg deleted file mode 100644 index bf8bfd41..00000000 Binary files a/screenshots/SS2-B-21.jpg and /dev/null differ diff --git a/screenshots/SS2-B-22.jpg b/screenshots/SS2-B-22.jpg deleted file mode 100644 index 4df74bd1..00000000 Binary files a/screenshots/SS2-B-22.jpg and /dev/null differ diff --git a/screenshots/SS2-B-23.jpg b/screenshots/SS2-B-23.jpg deleted file mode 100644 index 93c7ab91..00000000 Binary files a/screenshots/SS2-B-23.jpg and /dev/null differ diff --git a/screenshots/SS2-C-00.jpg b/screenshots/SS2-C-00.jpg deleted file mode 100644 index e47adfb3..00000000 Binary files a/screenshots/SS2-C-00.jpg and /dev/null differ diff --git a/screenshots/SS2-C-01.jpg b/screenshots/SS2-C-01.jpg deleted file mode 100644 index 422008a5..00000000 Binary files a/screenshots/SS2-C-01.jpg and /dev/null differ diff --git a/screenshots/SS2-C-02.jpg b/screenshots/SS2-C-02.jpg deleted file mode 100644 index 58300b12..00000000 Binary files a/screenshots/SS2-C-02.jpg and /dev/null differ diff --git a/screenshots/SS2-C-03.jpg b/screenshots/SS2-C-03.jpg deleted file mode 100644 index d23061a8..00000000 Binary files a/screenshots/SS2-C-03.jpg and /dev/null differ diff --git a/screenshots/SS2-C-04.jpg b/screenshots/SS2-C-04.jpg deleted file mode 100644 index 6c48f4ce..00000000 Binary files a/screenshots/SS2-C-04.jpg and /dev/null differ diff --git a/screenshots/SS2-C-05.jpg b/screenshots/SS2-C-05.jpg deleted file mode 100644 index 0d8be248..00000000 Binary files a/screenshots/SS2-C-05.jpg and /dev/null differ diff --git a/screenshots/SS2-C-06.jpg b/screenshots/SS2-C-06.jpg deleted file mode 100644 index d49adb6a..00000000 Binary files a/screenshots/SS2-C-06.jpg and /dev/null differ diff --git a/screenshots/SS2-C-07.jpg b/screenshots/SS2-C-07.jpg deleted file mode 100644 index 59199047..00000000 Binary files a/screenshots/SS2-C-07.jpg and /dev/null differ diff --git a/screenshots/SS2-C-08.jpg b/screenshots/SS2-C-08.jpg deleted file mode 100644 index 0e80e43e..00000000 Binary files a/screenshots/SS2-C-08.jpg and /dev/null differ diff --git a/screenshots/SS2-C-09.jpg b/screenshots/SS2-C-09.jpg deleted file mode 100644 index 7ec49af2..00000000 Binary files a/screenshots/SS2-C-09.jpg and /dev/null differ diff --git a/screenshots/SS2-C-10.jpg b/screenshots/SS2-C-10.jpg deleted file mode 100644 index 363cc036..00000000 Binary files a/screenshots/SS2-C-10.jpg and /dev/null differ diff --git a/screenshots/SS2-C-11.jpg b/screenshots/SS2-C-11.jpg deleted file mode 100644 index 1665904d..00000000 Binary files a/screenshots/SS2-C-11.jpg and /dev/null differ diff --git a/screenshots/SS2-C-12.jpg b/screenshots/SS2-C-12.jpg deleted file mode 100644 index 97945d2e..00000000 Binary files a/screenshots/SS2-C-12.jpg and /dev/null differ diff --git a/screenshots/SS2-C-13.jpg b/screenshots/SS2-C-13.jpg deleted file mode 100644 index 4e77ef00..00000000 Binary files a/screenshots/SS2-C-13.jpg and /dev/null differ diff --git a/screenshots/SS2-C-14.jpg b/screenshots/SS2-C-14.jpg deleted file mode 100644 index 6c68a2ce..00000000 Binary files a/screenshots/SS2-C-14.jpg and /dev/null differ diff --git a/screenshots/SS2-C-15.jpg b/screenshots/SS2-C-15.jpg deleted file mode 100644 index b7234c9c..00000000 Binary files a/screenshots/SS2-C-15.jpg and /dev/null differ diff --git a/screenshots/SS2-C-16.jpg b/screenshots/SS2-C-16.jpg deleted file mode 100644 index 2edfa190..00000000 Binary files a/screenshots/SS2-C-16.jpg and /dev/null differ diff --git a/screenshots/SS2-C-17.jpg b/screenshots/SS2-C-17.jpg deleted file mode 100644 index 377d9e1d..00000000 Binary files a/screenshots/SS2-C-17.jpg and /dev/null differ diff --git a/screenshots/SS2-C-18.jpg b/screenshots/SS2-C-18.jpg deleted file mode 100644 index e536ea96..00000000 Binary files a/screenshots/SS2-C-18.jpg and /dev/null differ diff --git a/screenshots/SS2-C-19.jpg b/screenshots/SS2-C-19.jpg deleted file mode 100644 index 2197ad9c..00000000 Binary files a/screenshots/SS2-C-19.jpg and /dev/null differ diff --git a/screenshots/SS2-C-20.jpg b/screenshots/SS2-C-20.jpg deleted file mode 100644 index 3f781017..00000000 Binary files a/screenshots/SS2-C-20.jpg and /dev/null differ diff --git a/screenshots/SS2-C-21.jpg b/screenshots/SS2-C-21.jpg deleted file mode 100644 index abc65614..00000000 Binary files a/screenshots/SS2-C-21.jpg and /dev/null differ diff --git a/screenshots/SS2-C-22.jpg b/screenshots/SS2-C-22.jpg deleted file mode 100644 index afdc037e..00000000 Binary files a/screenshots/SS2-C-22.jpg and /dev/null differ diff --git a/screenshots/SS2-C-23.jpg b/screenshots/SS2-C-23.jpg deleted file mode 100644 index 406c150e..00000000 Binary files a/screenshots/SS2-C-23.jpg and /dev/null differ diff --git a/screenshots/SS2-D-00.jpg b/screenshots/SS2-D-00.jpg deleted file mode 100644 index ed3cb33b..00000000 Binary files a/screenshots/SS2-D-00.jpg and /dev/null differ diff --git a/screenshots/SS2-D-01.jpg b/screenshots/SS2-D-01.jpg deleted file mode 100644 index 7039f685..00000000 Binary files a/screenshots/SS2-D-01.jpg and /dev/null differ diff --git a/screenshots/SS2-D-02.jpg b/screenshots/SS2-D-02.jpg deleted file mode 100644 index 0e532df4..00000000 Binary files a/screenshots/SS2-D-02.jpg and /dev/null differ diff --git a/screenshots/SS2-D-03.jpg b/screenshots/SS2-D-03.jpg deleted file mode 100644 index 85e82c08..00000000 Binary files a/screenshots/SS2-D-03.jpg and /dev/null differ diff --git a/screenshots/SS2-D-04.jpg b/screenshots/SS2-D-04.jpg deleted file mode 100644 index 81004ee7..00000000 Binary files a/screenshots/SS2-D-04.jpg and /dev/null differ diff --git a/screenshots/SS2-D-05.jpg b/screenshots/SS2-D-05.jpg deleted file mode 100644 index d75008e5..00000000 Binary files a/screenshots/SS2-D-05.jpg and /dev/null differ diff --git a/screenshots/SS2-D-06.jpg b/screenshots/SS2-D-06.jpg deleted file mode 100644 index 46663017..00000000 Binary files a/screenshots/SS2-D-06.jpg and /dev/null differ diff --git a/screenshots/SS2-D-07.jpg b/screenshots/SS2-D-07.jpg deleted file mode 100644 index 10ae1ba3..00000000 Binary files a/screenshots/SS2-D-07.jpg and /dev/null differ diff --git a/screenshots/SS2-D-08.jpg b/screenshots/SS2-D-08.jpg deleted file mode 100644 index 49c40be1..00000000 Binary files a/screenshots/SS2-D-08.jpg and /dev/null differ diff --git a/screenshots/SS2-D-09.jpg b/screenshots/SS2-D-09.jpg deleted file mode 100644 index 8b5c5fc7..00000000 Binary files a/screenshots/SS2-D-09.jpg and /dev/null differ diff --git a/screenshots/SS2-D-10.jpg b/screenshots/SS2-D-10.jpg deleted file mode 100644 index 4d2eea35..00000000 Binary files a/screenshots/SS2-D-10.jpg and /dev/null differ diff --git a/screenshots/SS2-D-11.jpg b/screenshots/SS2-D-11.jpg deleted file mode 100644 index 56f00cf1..00000000 Binary files a/screenshots/SS2-D-11.jpg and /dev/null differ diff --git a/screenshots/SS2-D-12.jpg b/screenshots/SS2-D-12.jpg deleted file mode 100644 index b7dc714f..00000000 Binary files a/screenshots/SS2-D-12.jpg and /dev/null differ diff --git a/screenshots/SS2-D-13.jpg b/screenshots/SS2-D-13.jpg deleted file mode 100644 index 0c50bc17..00000000 Binary files a/screenshots/SS2-D-13.jpg and /dev/null differ diff --git a/screenshots/SS2-D-14.jpg b/screenshots/SS2-D-14.jpg deleted file mode 100644 index 0ad559f8..00000000 Binary files a/screenshots/SS2-D-14.jpg and /dev/null differ diff --git a/screenshots/SS2-D-15.jpg b/screenshots/SS2-D-15.jpg deleted file mode 100644 index df5ee062..00000000 Binary files a/screenshots/SS2-D-15.jpg and /dev/null differ diff --git a/screenshots/SS2-D-16.jpg b/screenshots/SS2-D-16.jpg deleted file mode 100644 index f90ed150..00000000 Binary files a/screenshots/SS2-D-16.jpg and /dev/null differ diff --git a/screenshots/SS2-D-17.jpg b/screenshots/SS2-D-17.jpg deleted file mode 100644 index 9269e97b..00000000 Binary files a/screenshots/SS2-D-17.jpg and /dev/null differ diff --git a/screenshots/SS2-D-18.jpg b/screenshots/SS2-D-18.jpg deleted file mode 100644 index 606af11b..00000000 Binary files a/screenshots/SS2-D-18.jpg and /dev/null differ diff --git a/screenshots/SS2-D-19.jpg b/screenshots/SS2-D-19.jpg deleted file mode 100644 index 5d54fbb8..00000000 Binary files a/screenshots/SS2-D-19.jpg and /dev/null differ diff --git a/screenshots/SS2-D-20.jpg b/screenshots/SS2-D-20.jpg deleted file mode 100644 index fe75790b..00000000 Binary files a/screenshots/SS2-D-20.jpg and /dev/null differ diff --git a/screenshots/SS2-D-21.jpg b/screenshots/SS2-D-21.jpg deleted file mode 100644 index 61e3f26d..00000000 Binary files a/screenshots/SS2-D-21.jpg and /dev/null differ diff --git a/screenshots/SS2-D-22.jpg b/screenshots/SS2-D-22.jpg deleted file mode 100644 index 837efbb2..00000000 Binary files a/screenshots/SS2-D-22.jpg and /dev/null differ diff --git a/screenshots/SS2-D-23.jpg b/screenshots/SS2-D-23.jpg deleted file mode 100644 index 05a732cc..00000000 Binary files a/screenshots/SS2-D-23.jpg and /dev/null differ diff --git a/screenshots/SS2-E-00.jpg b/screenshots/SS2-E-00.jpg deleted file mode 100644 index 747ed573..00000000 Binary files a/screenshots/SS2-E-00.jpg and /dev/null differ diff --git a/screenshots/SS2-E-01.jpg b/screenshots/SS2-E-01.jpg deleted file mode 100644 index 0de400bf..00000000 Binary files a/screenshots/SS2-E-01.jpg and /dev/null differ diff --git a/screenshots/SS2-E-02.jpg b/screenshots/SS2-E-02.jpg deleted file mode 100644 index 19728b83..00000000 Binary files a/screenshots/SS2-E-02.jpg and /dev/null differ diff --git a/screenshots/SS2-E-03.jpg b/screenshots/SS2-E-03.jpg deleted file mode 100644 index 292edaff..00000000 Binary files a/screenshots/SS2-E-03.jpg and /dev/null differ diff --git a/screenshots/SS2-E-04.jpg b/screenshots/SS2-E-04.jpg deleted file mode 100644 index 5e15d3c1..00000000 Binary files a/screenshots/SS2-E-04.jpg and /dev/null differ diff --git a/screenshots/SS2-E-05.jpg b/screenshots/SS2-E-05.jpg deleted file mode 100644 index 15451e80..00000000 Binary files a/screenshots/SS2-E-05.jpg and /dev/null differ diff --git a/screenshots/SS2-E-06.jpg b/screenshots/SS2-E-06.jpg deleted file mode 100644 index 3583e650..00000000 Binary files a/screenshots/SS2-E-06.jpg and /dev/null differ diff --git a/screenshots/SS2-E-07.jpg b/screenshots/SS2-E-07.jpg deleted file mode 100644 index 30490e75..00000000 Binary files a/screenshots/SS2-E-07.jpg and /dev/null differ diff --git a/screenshots/SS2-E-08.jpg b/screenshots/SS2-E-08.jpg deleted file mode 100644 index 2e3bbe5b..00000000 Binary files a/screenshots/SS2-E-08.jpg and /dev/null differ diff --git a/screenshots/SS2-E-09.jpg b/screenshots/SS2-E-09.jpg deleted file mode 100644 index 6a80a1f9..00000000 Binary files a/screenshots/SS2-E-09.jpg and /dev/null differ diff --git a/screenshots/SS2-E-10.jpg b/screenshots/SS2-E-10.jpg deleted file mode 100644 index 77a8a1f3..00000000 Binary files a/screenshots/SS2-E-10.jpg and /dev/null differ diff --git a/screenshots/SS2-E-11.jpg b/screenshots/SS2-E-11.jpg deleted file mode 100644 index e1b0900f..00000000 Binary files a/screenshots/SS2-E-11.jpg and /dev/null differ diff --git a/screenshots/SS2-E-12.jpg b/screenshots/SS2-E-12.jpg deleted file mode 100644 index 1a64b2ae..00000000 Binary files a/screenshots/SS2-E-12.jpg and /dev/null differ diff --git a/screenshots/SS2-E-13.jpg b/screenshots/SS2-E-13.jpg deleted file mode 100644 index bccc8393..00000000 Binary files a/screenshots/SS2-E-13.jpg and /dev/null differ diff --git a/screenshots/SS2-E-14.jpg b/screenshots/SS2-E-14.jpg deleted file mode 100644 index 26e0bfae..00000000 Binary files a/screenshots/SS2-E-14.jpg and /dev/null differ diff --git a/screenshots/SS2-E-15.jpg b/screenshots/SS2-E-15.jpg deleted file mode 100644 index 7057848e..00000000 Binary files a/screenshots/SS2-E-15.jpg and /dev/null differ diff --git a/screenshots/SS2-E-16.jpg b/screenshots/SS2-E-16.jpg deleted file mode 100644 index 70dd86b2..00000000 Binary files a/screenshots/SS2-E-16.jpg and /dev/null differ diff --git a/screenshots/SS2-E-17.jpg b/screenshots/SS2-E-17.jpg deleted file mode 100644 index f2a81af2..00000000 Binary files a/screenshots/SS2-E-17.jpg and /dev/null differ diff --git a/screenshots/SS2-E-18.jpg b/screenshots/SS2-E-18.jpg deleted file mode 100644 index d47923aa..00000000 Binary files a/screenshots/SS2-E-18.jpg and /dev/null differ diff --git a/screenshots/SS2-E-19.jpg b/screenshots/SS2-E-19.jpg deleted file mode 100644 index afc9a149..00000000 Binary files a/screenshots/SS2-E-19.jpg and /dev/null differ diff --git a/screenshots/SS2-E-20.jpg b/screenshots/SS2-E-20.jpg deleted file mode 100644 index f1756fbc..00000000 Binary files a/screenshots/SS2-E-20.jpg and /dev/null differ diff --git a/screenshots/SS2-E-21.jpg b/screenshots/SS2-E-21.jpg deleted file mode 100644 index 3a545caa..00000000 Binary files a/screenshots/SS2-E-21.jpg and /dev/null differ diff --git a/screenshots/SS2-E-22.jpg b/screenshots/SS2-E-22.jpg deleted file mode 100644 index a9df4bd0..00000000 Binary files a/screenshots/SS2-E-22.jpg and /dev/null differ diff --git a/screenshots/SS2-E-23.jpg b/screenshots/SS2-E-23.jpg deleted file mode 100644 index c87cfcf3..00000000 Binary files a/screenshots/SS2-E-23.jpg and /dev/null differ diff --git a/screenshots/SU-A-00-00.jpg b/screenshots/SU-A-00-00.jpg deleted file mode 100644 index 2a25a5fd..00000000 Binary files a/screenshots/SU-A-00-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-00-01.jpg b/screenshots/SU-A-00-01.jpg deleted file mode 100644 index 6cbc803d..00000000 Binary files a/screenshots/SU-A-00-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-00-02.jpg b/screenshots/SU-A-00-02.jpg deleted file mode 100644 index 8cc2b555..00000000 Binary files a/screenshots/SU-A-00-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-00-03.jpg b/screenshots/SU-A-00-03.jpg deleted file mode 100644 index c6257168..00000000 Binary files a/screenshots/SU-A-00-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-00-04.jpg b/screenshots/SU-A-00-04.jpg deleted file mode 100644 index eecf71d1..00000000 Binary files a/screenshots/SU-A-00-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-00.jpg b/screenshots/SU-A-00.jpg deleted file mode 100644 index 3cef905b..00000000 Binary files a/screenshots/SU-A-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-01-00.jpg b/screenshots/SU-A-01-00.jpg deleted file mode 100644 index cc48dd7a..00000000 Binary files a/screenshots/SU-A-01-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-01-01.jpg b/screenshots/SU-A-01-01.jpg deleted file mode 100644 index 7b98796b..00000000 Binary files a/screenshots/SU-A-01-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-01-02.jpg b/screenshots/SU-A-01-02.jpg deleted file mode 100644 index 2a899cad..00000000 Binary files a/screenshots/SU-A-01-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-01-03.jpg b/screenshots/SU-A-01-03.jpg deleted file mode 100644 index 5132c515..00000000 Binary files a/screenshots/SU-A-01-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-01-04.jpg b/screenshots/SU-A-01-04.jpg deleted file mode 100644 index 1cf6ffd1..00000000 Binary files a/screenshots/SU-A-01-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-01.jpg b/screenshots/SU-A-01.jpg deleted file mode 100644 index d18d3fdb..00000000 Binary files a/screenshots/SU-A-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-02-00.jpg b/screenshots/SU-A-02-00.jpg deleted file mode 100644 index 0c7147e8..00000000 Binary files a/screenshots/SU-A-02-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-02-01.jpg b/screenshots/SU-A-02-01.jpg deleted file mode 100644 index 742bd56a..00000000 Binary files a/screenshots/SU-A-02-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-02-02.jpg b/screenshots/SU-A-02-02.jpg deleted file mode 100644 index d361fb96..00000000 Binary files a/screenshots/SU-A-02-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-02-03.jpg b/screenshots/SU-A-02-03.jpg deleted file mode 100644 index ea4131c1..00000000 Binary files a/screenshots/SU-A-02-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-02-04.jpg b/screenshots/SU-A-02-04.jpg deleted file mode 100644 index 8ebf0498..00000000 Binary files a/screenshots/SU-A-02-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-02.jpg b/screenshots/SU-A-02.jpg deleted file mode 100644 index dfe9115e..00000000 Binary files a/screenshots/SU-A-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-03-00.jpg b/screenshots/SU-A-03-00.jpg deleted file mode 100644 index 3282b8a9..00000000 Binary files a/screenshots/SU-A-03-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-03-01.jpg b/screenshots/SU-A-03-01.jpg deleted file mode 100644 index 48de9a51..00000000 Binary files a/screenshots/SU-A-03-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-03-02.jpg b/screenshots/SU-A-03-02.jpg deleted file mode 100644 index 7fe3f068..00000000 Binary files a/screenshots/SU-A-03-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-03-03.jpg b/screenshots/SU-A-03-03.jpg deleted file mode 100644 index 3deae1f4..00000000 Binary files a/screenshots/SU-A-03-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-03-04.jpg b/screenshots/SU-A-03-04.jpg deleted file mode 100644 index 1909a45d..00000000 Binary files a/screenshots/SU-A-03-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-03.jpg b/screenshots/SU-A-03.jpg deleted file mode 100644 index 7f813bee..00000000 Binary files a/screenshots/SU-A-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-04-00.jpg b/screenshots/SU-A-04-00.jpg deleted file mode 100644 index 8554d09d..00000000 Binary files a/screenshots/SU-A-04-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-04-01.jpg b/screenshots/SU-A-04-01.jpg deleted file mode 100644 index f69a6bde..00000000 Binary files a/screenshots/SU-A-04-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-04-02.jpg b/screenshots/SU-A-04-02.jpg deleted file mode 100644 index 7651a8ec..00000000 Binary files a/screenshots/SU-A-04-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-04-03.jpg b/screenshots/SU-A-04-03.jpg deleted file mode 100644 index 27414f55..00000000 Binary files a/screenshots/SU-A-04-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-04-04.jpg b/screenshots/SU-A-04-04.jpg deleted file mode 100644 index a9bc78aa..00000000 Binary files a/screenshots/SU-A-04-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-04.jpg b/screenshots/SU-A-04.jpg deleted file mode 100644 index e4c400c6..00000000 Binary files a/screenshots/SU-A-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-05-00.jpg b/screenshots/SU-A-05-00.jpg deleted file mode 100644 index b1eb7b22..00000000 Binary files a/screenshots/SU-A-05-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-05-01.jpg b/screenshots/SU-A-05-01.jpg deleted file mode 100644 index 3d280f3a..00000000 Binary files a/screenshots/SU-A-05-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-05-02.jpg b/screenshots/SU-A-05-02.jpg deleted file mode 100644 index f2341d89..00000000 Binary files a/screenshots/SU-A-05-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-05-03.jpg b/screenshots/SU-A-05-03.jpg deleted file mode 100644 index 7da3df90..00000000 Binary files a/screenshots/SU-A-05-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-05-04.jpg b/screenshots/SU-A-05-04.jpg deleted file mode 100644 index 688a86f0..00000000 Binary files a/screenshots/SU-A-05-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-05.jpg b/screenshots/SU-A-05.jpg deleted file mode 100644 index ed8bb2c7..00000000 Binary files a/screenshots/SU-A-05.jpg and /dev/null differ diff --git a/screenshots/SU-A-06-00.jpg b/screenshots/SU-A-06-00.jpg deleted file mode 100644 index b24a5662..00000000 Binary files a/screenshots/SU-A-06-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-06-01.jpg b/screenshots/SU-A-06-01.jpg deleted file mode 100644 index ff8fa408..00000000 Binary files a/screenshots/SU-A-06-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-06-02.jpg b/screenshots/SU-A-06-02.jpg deleted file mode 100644 index f077297d..00000000 Binary files a/screenshots/SU-A-06-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-06-03.jpg b/screenshots/SU-A-06-03.jpg deleted file mode 100644 index b03490cc..00000000 Binary files a/screenshots/SU-A-06-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-06-04.jpg b/screenshots/SU-A-06-04.jpg deleted file mode 100644 index 25bbcb1b..00000000 Binary files a/screenshots/SU-A-06-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-06.jpg b/screenshots/SU-A-06.jpg deleted file mode 100644 index 6227ffde..00000000 Binary files a/screenshots/SU-A-06.jpg and /dev/null differ diff --git a/screenshots/SU-A-07-00.jpg b/screenshots/SU-A-07-00.jpg deleted file mode 100644 index b8056d02..00000000 Binary files a/screenshots/SU-A-07-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-07-01.jpg b/screenshots/SU-A-07-01.jpg deleted file mode 100644 index 356fd324..00000000 Binary files a/screenshots/SU-A-07-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-07-02.jpg b/screenshots/SU-A-07-02.jpg deleted file mode 100644 index f5025be1..00000000 Binary files a/screenshots/SU-A-07-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-07-03.jpg b/screenshots/SU-A-07-03.jpg deleted file mode 100644 index 07ece8ee..00000000 Binary files a/screenshots/SU-A-07-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-07-04.jpg b/screenshots/SU-A-07-04.jpg deleted file mode 100644 index df3205c1..00000000 Binary files a/screenshots/SU-A-07-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-07.jpg b/screenshots/SU-A-07.jpg deleted file mode 100644 index 29313aa3..00000000 Binary files a/screenshots/SU-A-07.jpg and /dev/null differ diff --git a/screenshots/SU-A-08-00.jpg b/screenshots/SU-A-08-00.jpg deleted file mode 100644 index 40c87eb1..00000000 Binary files a/screenshots/SU-A-08-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-08-01.jpg b/screenshots/SU-A-08-01.jpg deleted file mode 100644 index ba9a85bd..00000000 Binary files a/screenshots/SU-A-08-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-08-02.jpg b/screenshots/SU-A-08-02.jpg deleted file mode 100644 index fbf8c495..00000000 Binary files a/screenshots/SU-A-08-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-08-03.jpg b/screenshots/SU-A-08-03.jpg deleted file mode 100644 index a4bdbb3e..00000000 Binary files a/screenshots/SU-A-08-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-08-04.jpg b/screenshots/SU-A-08-04.jpg deleted file mode 100644 index bf8f7582..00000000 Binary files a/screenshots/SU-A-08-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-08.jpg b/screenshots/SU-A-08.jpg deleted file mode 100644 index 82e52ec8..00000000 Binary files a/screenshots/SU-A-08.jpg and /dev/null differ diff --git a/screenshots/SU-A-09-00.jpg b/screenshots/SU-A-09-00.jpg deleted file mode 100644 index ed98c1bc..00000000 Binary files a/screenshots/SU-A-09-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-09-01.jpg b/screenshots/SU-A-09-01.jpg deleted file mode 100644 index 6cba9836..00000000 Binary files a/screenshots/SU-A-09-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-09-02.jpg b/screenshots/SU-A-09-02.jpg deleted file mode 100644 index 44f6961d..00000000 Binary files a/screenshots/SU-A-09-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-09-03.jpg b/screenshots/SU-A-09-03.jpg deleted file mode 100644 index 6cbf24f6..00000000 Binary files a/screenshots/SU-A-09-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-09-04.jpg b/screenshots/SU-A-09-04.jpg deleted file mode 100644 index a01293b8..00000000 Binary files a/screenshots/SU-A-09-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-09.jpg b/screenshots/SU-A-09.jpg deleted file mode 100644 index 70fcc438..00000000 Binary files a/screenshots/SU-A-09.jpg and /dev/null differ diff --git a/screenshots/SU-A-10-00.jpg b/screenshots/SU-A-10-00.jpg deleted file mode 100644 index e581d584..00000000 Binary files a/screenshots/SU-A-10-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-10-01.jpg b/screenshots/SU-A-10-01.jpg deleted file mode 100644 index dc0e4ec6..00000000 Binary files a/screenshots/SU-A-10-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-10-02.jpg b/screenshots/SU-A-10-02.jpg deleted file mode 100644 index 7146ff62..00000000 Binary files a/screenshots/SU-A-10-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-10-03.jpg b/screenshots/SU-A-10-03.jpg deleted file mode 100644 index 61cb4db9..00000000 Binary files a/screenshots/SU-A-10-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-10-04.jpg b/screenshots/SU-A-10-04.jpg deleted file mode 100644 index 1114875f..00000000 Binary files a/screenshots/SU-A-10-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-10.jpg b/screenshots/SU-A-10.jpg deleted file mode 100644 index 46f805e2..00000000 Binary files a/screenshots/SU-A-10.jpg and /dev/null differ diff --git a/screenshots/SU-A-11-00.jpg b/screenshots/SU-A-11-00.jpg deleted file mode 100644 index 43000fbb..00000000 Binary files a/screenshots/SU-A-11-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-11-01.jpg b/screenshots/SU-A-11-01.jpg deleted file mode 100644 index 75fe9c80..00000000 Binary files a/screenshots/SU-A-11-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-11-02.jpg b/screenshots/SU-A-11-02.jpg deleted file mode 100644 index 46e8c5b1..00000000 Binary files a/screenshots/SU-A-11-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-11-03.jpg b/screenshots/SU-A-11-03.jpg deleted file mode 100644 index 3c55cd92..00000000 Binary files a/screenshots/SU-A-11-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-11-04.jpg b/screenshots/SU-A-11-04.jpg deleted file mode 100644 index d6051cbf..00000000 Binary files a/screenshots/SU-A-11-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-11.jpg b/screenshots/SU-A-11.jpg deleted file mode 100644 index 4a440dfd..00000000 Binary files a/screenshots/SU-A-11.jpg and /dev/null differ diff --git a/screenshots/SU-A-12-00.jpg b/screenshots/SU-A-12-00.jpg deleted file mode 100644 index 265f83e6..00000000 Binary files a/screenshots/SU-A-12-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-12-01.jpg b/screenshots/SU-A-12-01.jpg deleted file mode 100644 index ccfbfc9b..00000000 Binary files a/screenshots/SU-A-12-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-12-02.jpg b/screenshots/SU-A-12-02.jpg deleted file mode 100644 index 85fac808..00000000 Binary files a/screenshots/SU-A-12-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-12-03.jpg b/screenshots/SU-A-12-03.jpg deleted file mode 100644 index 58ec3560..00000000 Binary files a/screenshots/SU-A-12-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-12-04.jpg b/screenshots/SU-A-12-04.jpg deleted file mode 100644 index ce54038b..00000000 Binary files a/screenshots/SU-A-12-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-12.jpg b/screenshots/SU-A-12.jpg deleted file mode 100644 index bb069491..00000000 Binary files a/screenshots/SU-A-12.jpg and /dev/null differ diff --git a/screenshots/SU-A-13-00.jpg b/screenshots/SU-A-13-00.jpg deleted file mode 100644 index c47a9520..00000000 Binary files a/screenshots/SU-A-13-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-13-01.jpg b/screenshots/SU-A-13-01.jpg deleted file mode 100644 index a9203a63..00000000 Binary files a/screenshots/SU-A-13-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-13-02.jpg b/screenshots/SU-A-13-02.jpg deleted file mode 100644 index cc345298..00000000 Binary files a/screenshots/SU-A-13-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-13-03.jpg b/screenshots/SU-A-13-03.jpg deleted file mode 100644 index 6dc0fe04..00000000 Binary files a/screenshots/SU-A-13-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-13-04.jpg b/screenshots/SU-A-13-04.jpg deleted file mode 100644 index a75fd029..00000000 Binary files a/screenshots/SU-A-13-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-13.jpg b/screenshots/SU-A-13.jpg deleted file mode 100644 index df45f270..00000000 Binary files a/screenshots/SU-A-13.jpg and /dev/null differ diff --git a/screenshots/SU-A-14-00.jpg b/screenshots/SU-A-14-00.jpg deleted file mode 100644 index 99c66921..00000000 Binary files a/screenshots/SU-A-14-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-14-01.jpg b/screenshots/SU-A-14-01.jpg deleted file mode 100644 index 1bd3c59c..00000000 Binary files a/screenshots/SU-A-14-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-14-02.jpg b/screenshots/SU-A-14-02.jpg deleted file mode 100644 index f74d2e04..00000000 Binary files a/screenshots/SU-A-14-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-14-03.jpg b/screenshots/SU-A-14-03.jpg deleted file mode 100644 index 08b727c2..00000000 Binary files a/screenshots/SU-A-14-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-14-04.jpg b/screenshots/SU-A-14-04.jpg deleted file mode 100644 index af4715cd..00000000 Binary files a/screenshots/SU-A-14-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-14.jpg b/screenshots/SU-A-14.jpg deleted file mode 100644 index f426b20a..00000000 Binary files a/screenshots/SU-A-14.jpg and /dev/null differ diff --git a/screenshots/SU-A-15-00.jpg b/screenshots/SU-A-15-00.jpg deleted file mode 100644 index c08ba9c2..00000000 Binary files a/screenshots/SU-A-15-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-15-01.jpg b/screenshots/SU-A-15-01.jpg deleted file mode 100644 index c426184c..00000000 Binary files a/screenshots/SU-A-15-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-15-02.jpg b/screenshots/SU-A-15-02.jpg deleted file mode 100644 index d34796eb..00000000 Binary files a/screenshots/SU-A-15-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-15-03.jpg b/screenshots/SU-A-15-03.jpg deleted file mode 100644 index 03382472..00000000 Binary files a/screenshots/SU-A-15-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-15-04.jpg b/screenshots/SU-A-15-04.jpg deleted file mode 100644 index badf07a1..00000000 Binary files a/screenshots/SU-A-15-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-15.jpg b/screenshots/SU-A-15.jpg deleted file mode 100644 index 7d952306..00000000 Binary files a/screenshots/SU-A-15.jpg and /dev/null differ diff --git a/screenshots/SU-A-16-00.jpg b/screenshots/SU-A-16-00.jpg deleted file mode 100644 index 1e107e0c..00000000 Binary files a/screenshots/SU-A-16-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-16-01.jpg b/screenshots/SU-A-16-01.jpg deleted file mode 100644 index 0d124b98..00000000 Binary files a/screenshots/SU-A-16-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-16-02.jpg b/screenshots/SU-A-16-02.jpg deleted file mode 100644 index a83ab178..00000000 Binary files a/screenshots/SU-A-16-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-16-03.jpg b/screenshots/SU-A-16-03.jpg deleted file mode 100644 index 0baf3c35..00000000 Binary files a/screenshots/SU-A-16-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-16-04.jpg b/screenshots/SU-A-16-04.jpg deleted file mode 100644 index 01e790f8..00000000 Binary files a/screenshots/SU-A-16-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-16.jpg b/screenshots/SU-A-16.jpg deleted file mode 100644 index 4f590c23..00000000 Binary files a/screenshots/SU-A-16.jpg and /dev/null differ diff --git a/screenshots/SU-A-17-00.jpg b/screenshots/SU-A-17-00.jpg deleted file mode 100644 index 394c29af..00000000 Binary files a/screenshots/SU-A-17-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-17-01.jpg b/screenshots/SU-A-17-01.jpg deleted file mode 100644 index 46c4ac7b..00000000 Binary files a/screenshots/SU-A-17-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-17-02.jpg b/screenshots/SU-A-17-02.jpg deleted file mode 100644 index b1bb3a11..00000000 Binary files a/screenshots/SU-A-17-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-17-03.jpg b/screenshots/SU-A-17-03.jpg deleted file mode 100644 index 4468c3bb..00000000 Binary files a/screenshots/SU-A-17-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-17-04.jpg b/screenshots/SU-A-17-04.jpg deleted file mode 100644 index f9bd729a..00000000 Binary files a/screenshots/SU-A-17-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-17.jpg b/screenshots/SU-A-17.jpg deleted file mode 100644 index da708b55..00000000 Binary files a/screenshots/SU-A-17.jpg and /dev/null differ diff --git a/screenshots/SU-A-18-00.jpg b/screenshots/SU-A-18-00.jpg deleted file mode 100644 index 41da022f..00000000 Binary files a/screenshots/SU-A-18-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-18-01.jpg b/screenshots/SU-A-18-01.jpg deleted file mode 100644 index 76c08014..00000000 Binary files a/screenshots/SU-A-18-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-18-02.jpg b/screenshots/SU-A-18-02.jpg deleted file mode 100644 index 9bd07496..00000000 Binary files a/screenshots/SU-A-18-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-18-03.jpg b/screenshots/SU-A-18-03.jpg deleted file mode 100644 index 92e677fe..00000000 Binary files a/screenshots/SU-A-18-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-18-04.jpg b/screenshots/SU-A-18-04.jpg deleted file mode 100644 index 34b2768c..00000000 Binary files a/screenshots/SU-A-18-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-18.jpg b/screenshots/SU-A-18.jpg deleted file mode 100644 index d21ad481..00000000 Binary files a/screenshots/SU-A-18.jpg and /dev/null differ diff --git a/screenshots/SU-A-19-00.jpg b/screenshots/SU-A-19-00.jpg deleted file mode 100644 index 3af926da..00000000 Binary files a/screenshots/SU-A-19-00.jpg and /dev/null differ diff --git a/screenshots/SU-A-19-01.jpg b/screenshots/SU-A-19-01.jpg deleted file mode 100644 index 95a9e19b..00000000 Binary files a/screenshots/SU-A-19-01.jpg and /dev/null differ diff --git a/screenshots/SU-A-19-02.jpg b/screenshots/SU-A-19-02.jpg deleted file mode 100644 index 8d25df66..00000000 Binary files a/screenshots/SU-A-19-02.jpg and /dev/null differ diff --git a/screenshots/SU-A-19-03.jpg b/screenshots/SU-A-19-03.jpg deleted file mode 100644 index ab23b5b9..00000000 Binary files a/screenshots/SU-A-19-03.jpg and /dev/null differ diff --git a/screenshots/SU-A-19-04.jpg b/screenshots/SU-A-19-04.jpg deleted file mode 100644 index ea8693fe..00000000 Binary files a/screenshots/SU-A-19-04.jpg and /dev/null differ diff --git a/screenshots/SU-A-19.jpg b/screenshots/SU-A-19.jpg deleted file mode 100644 index c9579082..00000000 Binary files a/screenshots/SU-A-19.jpg and /dev/null differ diff --git a/screenshots/SU-B-00-00.jpg b/screenshots/SU-B-00-00.jpg deleted file mode 100644 index 09035103..00000000 Binary files a/screenshots/SU-B-00-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-00-01.jpg b/screenshots/SU-B-00-01.jpg deleted file mode 100644 index 7b293505..00000000 Binary files a/screenshots/SU-B-00-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-00-02.jpg b/screenshots/SU-B-00-02.jpg deleted file mode 100644 index efeecbd1..00000000 Binary files a/screenshots/SU-B-00-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-00-03.jpg b/screenshots/SU-B-00-03.jpg deleted file mode 100644 index 60b055eb..00000000 Binary files a/screenshots/SU-B-00-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-00-04.jpg b/screenshots/SU-B-00-04.jpg deleted file mode 100644 index f10b080d..00000000 Binary files a/screenshots/SU-B-00-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-00.jpg b/screenshots/SU-B-00.jpg deleted file mode 100644 index 41ca9f23..00000000 Binary files a/screenshots/SU-B-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-01-00.jpg b/screenshots/SU-B-01-00.jpg deleted file mode 100644 index c98e5454..00000000 Binary files a/screenshots/SU-B-01-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-01-01.jpg b/screenshots/SU-B-01-01.jpg deleted file mode 100644 index 67d7d5f4..00000000 Binary files a/screenshots/SU-B-01-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-01-02.jpg b/screenshots/SU-B-01-02.jpg deleted file mode 100644 index b2b91a54..00000000 Binary files a/screenshots/SU-B-01-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-01-03.jpg b/screenshots/SU-B-01-03.jpg deleted file mode 100644 index dbcd9cfb..00000000 Binary files a/screenshots/SU-B-01-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-01-04.jpg b/screenshots/SU-B-01-04.jpg deleted file mode 100644 index 298f0034..00000000 Binary files a/screenshots/SU-B-01-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-01.jpg b/screenshots/SU-B-01.jpg deleted file mode 100644 index 0f3aefeb..00000000 Binary files a/screenshots/SU-B-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-02-00.jpg b/screenshots/SU-B-02-00.jpg deleted file mode 100644 index c96bf4b1..00000000 Binary files a/screenshots/SU-B-02-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-02-01.jpg b/screenshots/SU-B-02-01.jpg deleted file mode 100644 index 16c6b6c6..00000000 Binary files a/screenshots/SU-B-02-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-02-02.jpg b/screenshots/SU-B-02-02.jpg deleted file mode 100644 index 8e46104a..00000000 Binary files a/screenshots/SU-B-02-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-02-03.jpg b/screenshots/SU-B-02-03.jpg deleted file mode 100644 index 261ebe4c..00000000 Binary files a/screenshots/SU-B-02-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-02-04.jpg b/screenshots/SU-B-02-04.jpg deleted file mode 100644 index 506760b0..00000000 Binary files a/screenshots/SU-B-02-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-02.jpg b/screenshots/SU-B-02.jpg deleted file mode 100644 index a0da5678..00000000 Binary files a/screenshots/SU-B-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-03-00.jpg b/screenshots/SU-B-03-00.jpg deleted file mode 100644 index 83c59d53..00000000 Binary files a/screenshots/SU-B-03-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-03-01.jpg b/screenshots/SU-B-03-01.jpg deleted file mode 100644 index 92251874..00000000 Binary files a/screenshots/SU-B-03-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-03-02.jpg b/screenshots/SU-B-03-02.jpg deleted file mode 100644 index c47de6ed..00000000 Binary files a/screenshots/SU-B-03-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-03-03.jpg b/screenshots/SU-B-03-03.jpg deleted file mode 100644 index eead2bff..00000000 Binary files a/screenshots/SU-B-03-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-03-04.jpg b/screenshots/SU-B-03-04.jpg deleted file mode 100644 index 681669d8..00000000 Binary files a/screenshots/SU-B-03-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-03.jpg b/screenshots/SU-B-03.jpg deleted file mode 100644 index 3c27d1a7..00000000 Binary files a/screenshots/SU-B-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-04-00.jpg b/screenshots/SU-B-04-00.jpg deleted file mode 100644 index 9dcd387e..00000000 Binary files a/screenshots/SU-B-04-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-04-01.jpg b/screenshots/SU-B-04-01.jpg deleted file mode 100644 index 11b22323..00000000 Binary files a/screenshots/SU-B-04-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-04-02.jpg b/screenshots/SU-B-04-02.jpg deleted file mode 100644 index fb426cd4..00000000 Binary files a/screenshots/SU-B-04-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-04-03.jpg b/screenshots/SU-B-04-03.jpg deleted file mode 100644 index 6478aec4..00000000 Binary files a/screenshots/SU-B-04-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-04-04.jpg b/screenshots/SU-B-04-04.jpg deleted file mode 100644 index 82e8cc9f..00000000 Binary files a/screenshots/SU-B-04-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-04.jpg b/screenshots/SU-B-04.jpg deleted file mode 100644 index 1c2ee531..00000000 Binary files a/screenshots/SU-B-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-05-00.jpg b/screenshots/SU-B-05-00.jpg deleted file mode 100644 index 5b2185cd..00000000 Binary files a/screenshots/SU-B-05-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-05-01.jpg b/screenshots/SU-B-05-01.jpg deleted file mode 100644 index 5748e379..00000000 Binary files a/screenshots/SU-B-05-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-05-02.jpg b/screenshots/SU-B-05-02.jpg deleted file mode 100644 index 60420a5d..00000000 Binary files a/screenshots/SU-B-05-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-05-03.jpg b/screenshots/SU-B-05-03.jpg deleted file mode 100644 index 8b6551f8..00000000 Binary files a/screenshots/SU-B-05-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-05-04.jpg b/screenshots/SU-B-05-04.jpg deleted file mode 100644 index 8d0216d2..00000000 Binary files a/screenshots/SU-B-05-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-05.jpg b/screenshots/SU-B-05.jpg deleted file mode 100644 index 93de78e6..00000000 Binary files a/screenshots/SU-B-05.jpg and /dev/null differ diff --git a/screenshots/SU-B-06-00.jpg b/screenshots/SU-B-06-00.jpg deleted file mode 100644 index 9b7be39f..00000000 Binary files a/screenshots/SU-B-06-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-06-01.jpg b/screenshots/SU-B-06-01.jpg deleted file mode 100644 index 2e936f4c..00000000 Binary files a/screenshots/SU-B-06-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-06-02.jpg b/screenshots/SU-B-06-02.jpg deleted file mode 100644 index 4f940c87..00000000 Binary files a/screenshots/SU-B-06-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-06-03.jpg b/screenshots/SU-B-06-03.jpg deleted file mode 100644 index 80c986d7..00000000 Binary files a/screenshots/SU-B-06-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-06-04.jpg b/screenshots/SU-B-06-04.jpg deleted file mode 100644 index 132d16a8..00000000 Binary files a/screenshots/SU-B-06-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-06.jpg b/screenshots/SU-B-06.jpg deleted file mode 100644 index e7ad1bd3..00000000 Binary files a/screenshots/SU-B-06.jpg and /dev/null differ diff --git a/screenshots/SU-B-07-00.jpg b/screenshots/SU-B-07-00.jpg deleted file mode 100644 index 21264a9a..00000000 Binary files a/screenshots/SU-B-07-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-07-01.jpg b/screenshots/SU-B-07-01.jpg deleted file mode 100644 index 93399da5..00000000 Binary files a/screenshots/SU-B-07-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-07-02.jpg b/screenshots/SU-B-07-02.jpg deleted file mode 100644 index 8bcc7b59..00000000 Binary files a/screenshots/SU-B-07-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-07-03.jpg b/screenshots/SU-B-07-03.jpg deleted file mode 100644 index 0f88966f..00000000 Binary files a/screenshots/SU-B-07-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-07-04.jpg b/screenshots/SU-B-07-04.jpg deleted file mode 100644 index 3c696ec4..00000000 Binary files a/screenshots/SU-B-07-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-07.jpg b/screenshots/SU-B-07.jpg deleted file mode 100644 index 452f726c..00000000 Binary files a/screenshots/SU-B-07.jpg and /dev/null differ diff --git a/screenshots/SU-B-08-00.jpg b/screenshots/SU-B-08-00.jpg deleted file mode 100644 index 48e93557..00000000 Binary files a/screenshots/SU-B-08-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-08-01.jpg b/screenshots/SU-B-08-01.jpg deleted file mode 100644 index 023c424d..00000000 Binary files a/screenshots/SU-B-08-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-08-02.jpg b/screenshots/SU-B-08-02.jpg deleted file mode 100644 index d1a53e6b..00000000 Binary files a/screenshots/SU-B-08-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-08-03.jpg b/screenshots/SU-B-08-03.jpg deleted file mode 100644 index 0bd090b3..00000000 Binary files a/screenshots/SU-B-08-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-08-04.jpg b/screenshots/SU-B-08-04.jpg deleted file mode 100644 index ee798782..00000000 Binary files a/screenshots/SU-B-08-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-08.jpg b/screenshots/SU-B-08.jpg deleted file mode 100644 index 5afd1001..00000000 Binary files a/screenshots/SU-B-08.jpg and /dev/null differ diff --git a/screenshots/SU-B-09-00.jpg b/screenshots/SU-B-09-00.jpg deleted file mode 100644 index 653df7e0..00000000 Binary files a/screenshots/SU-B-09-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-09-01.jpg b/screenshots/SU-B-09-01.jpg deleted file mode 100644 index 81f7a5f9..00000000 Binary files a/screenshots/SU-B-09-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-09-02.jpg b/screenshots/SU-B-09-02.jpg deleted file mode 100644 index f2ddadd7..00000000 Binary files a/screenshots/SU-B-09-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-09-03.jpg b/screenshots/SU-B-09-03.jpg deleted file mode 100644 index b1b3bb40..00000000 Binary files a/screenshots/SU-B-09-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-09-04.jpg b/screenshots/SU-B-09-04.jpg deleted file mode 100644 index 6a330b62..00000000 Binary files a/screenshots/SU-B-09-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-09.jpg b/screenshots/SU-B-09.jpg deleted file mode 100644 index a5a210ca..00000000 Binary files a/screenshots/SU-B-09.jpg and /dev/null differ diff --git a/screenshots/SU-B-10-00.jpg b/screenshots/SU-B-10-00.jpg deleted file mode 100644 index c372f525..00000000 Binary files a/screenshots/SU-B-10-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-10-01.jpg b/screenshots/SU-B-10-01.jpg deleted file mode 100644 index 53362a55..00000000 Binary files a/screenshots/SU-B-10-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-10-02.jpg b/screenshots/SU-B-10-02.jpg deleted file mode 100644 index a0069fb5..00000000 Binary files a/screenshots/SU-B-10-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-10-03.jpg b/screenshots/SU-B-10-03.jpg deleted file mode 100644 index 62d7e82a..00000000 Binary files a/screenshots/SU-B-10-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-10-04.jpg b/screenshots/SU-B-10-04.jpg deleted file mode 100644 index 2c32a344..00000000 Binary files a/screenshots/SU-B-10-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-10.jpg b/screenshots/SU-B-10.jpg deleted file mode 100644 index 2595e0b1..00000000 Binary files a/screenshots/SU-B-10.jpg and /dev/null differ diff --git a/screenshots/SU-B-11-00.jpg b/screenshots/SU-B-11-00.jpg deleted file mode 100644 index e3d42919..00000000 Binary files a/screenshots/SU-B-11-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-11-01.jpg b/screenshots/SU-B-11-01.jpg deleted file mode 100644 index 8ac4b111..00000000 Binary files a/screenshots/SU-B-11-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-11-02.jpg b/screenshots/SU-B-11-02.jpg deleted file mode 100644 index 45689613..00000000 Binary files a/screenshots/SU-B-11-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-11-03.jpg b/screenshots/SU-B-11-03.jpg deleted file mode 100644 index 1a77ea77..00000000 Binary files a/screenshots/SU-B-11-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-11-04.jpg b/screenshots/SU-B-11-04.jpg deleted file mode 100644 index 474f8d37..00000000 Binary files a/screenshots/SU-B-11-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-11.jpg b/screenshots/SU-B-11.jpg deleted file mode 100644 index 64b5ea89..00000000 Binary files a/screenshots/SU-B-11.jpg and /dev/null differ diff --git a/screenshots/SU-B-12-00.jpg b/screenshots/SU-B-12-00.jpg deleted file mode 100644 index 21fbca30..00000000 Binary files a/screenshots/SU-B-12-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-12-01.jpg b/screenshots/SU-B-12-01.jpg deleted file mode 100644 index 5465d774..00000000 Binary files a/screenshots/SU-B-12-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-12-02.jpg b/screenshots/SU-B-12-02.jpg deleted file mode 100644 index 72652fb7..00000000 Binary files a/screenshots/SU-B-12-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-12-03.jpg b/screenshots/SU-B-12-03.jpg deleted file mode 100644 index 564e371a..00000000 Binary files a/screenshots/SU-B-12-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-12-04.jpg b/screenshots/SU-B-12-04.jpg deleted file mode 100644 index ce3debf3..00000000 Binary files a/screenshots/SU-B-12-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-12.jpg b/screenshots/SU-B-12.jpg deleted file mode 100644 index 4d09bb3d..00000000 Binary files a/screenshots/SU-B-12.jpg and /dev/null differ diff --git a/screenshots/SU-B-13-00.jpg b/screenshots/SU-B-13-00.jpg deleted file mode 100644 index a9583024..00000000 Binary files a/screenshots/SU-B-13-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-13-01.jpg b/screenshots/SU-B-13-01.jpg deleted file mode 100644 index 71f95eb4..00000000 Binary files a/screenshots/SU-B-13-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-13-02.jpg b/screenshots/SU-B-13-02.jpg deleted file mode 100644 index 12ef5d8d..00000000 Binary files a/screenshots/SU-B-13-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-13-03.jpg b/screenshots/SU-B-13-03.jpg deleted file mode 100644 index ca35d5b8..00000000 Binary files a/screenshots/SU-B-13-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-13-04.jpg b/screenshots/SU-B-13-04.jpg deleted file mode 100644 index 234944f9..00000000 Binary files a/screenshots/SU-B-13-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-13.jpg b/screenshots/SU-B-13.jpg deleted file mode 100644 index fe9c810e..00000000 Binary files a/screenshots/SU-B-13.jpg and /dev/null differ diff --git a/screenshots/SU-B-14-00.jpg b/screenshots/SU-B-14-00.jpg deleted file mode 100644 index 86bee284..00000000 Binary files a/screenshots/SU-B-14-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-14-01.jpg b/screenshots/SU-B-14-01.jpg deleted file mode 100644 index 74c97349..00000000 Binary files a/screenshots/SU-B-14-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-14-02.jpg b/screenshots/SU-B-14-02.jpg deleted file mode 100644 index 66ab06d7..00000000 Binary files a/screenshots/SU-B-14-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-14-03.jpg b/screenshots/SU-B-14-03.jpg deleted file mode 100644 index 802d928a..00000000 Binary files a/screenshots/SU-B-14-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-14-04.jpg b/screenshots/SU-B-14-04.jpg deleted file mode 100644 index c279f580..00000000 Binary files a/screenshots/SU-B-14-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-14.jpg b/screenshots/SU-B-14.jpg deleted file mode 100644 index ec69741d..00000000 Binary files a/screenshots/SU-B-14.jpg and /dev/null differ diff --git a/screenshots/SU-B-15-00.jpg b/screenshots/SU-B-15-00.jpg deleted file mode 100644 index 874944e9..00000000 Binary files a/screenshots/SU-B-15-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-15-01.jpg b/screenshots/SU-B-15-01.jpg deleted file mode 100644 index 57d60dd6..00000000 Binary files a/screenshots/SU-B-15-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-15-02.jpg b/screenshots/SU-B-15-02.jpg deleted file mode 100644 index f938098f..00000000 Binary files a/screenshots/SU-B-15-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-15-03.jpg b/screenshots/SU-B-15-03.jpg deleted file mode 100644 index 0e9195e2..00000000 Binary files a/screenshots/SU-B-15-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-15-04.jpg b/screenshots/SU-B-15-04.jpg deleted file mode 100644 index c1f0bb93..00000000 Binary files a/screenshots/SU-B-15-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-15.jpg b/screenshots/SU-B-15.jpg deleted file mode 100644 index 622b17eb..00000000 Binary files a/screenshots/SU-B-15.jpg and /dev/null differ diff --git a/screenshots/SU-B-16-00.jpg b/screenshots/SU-B-16-00.jpg deleted file mode 100644 index f1ee1bfc..00000000 Binary files a/screenshots/SU-B-16-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-16-01.jpg b/screenshots/SU-B-16-01.jpg deleted file mode 100644 index 4a16a1d0..00000000 Binary files a/screenshots/SU-B-16-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-16-02.jpg b/screenshots/SU-B-16-02.jpg deleted file mode 100644 index eb73924a..00000000 Binary files a/screenshots/SU-B-16-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-16-03.jpg b/screenshots/SU-B-16-03.jpg deleted file mode 100644 index 35de2f22..00000000 Binary files a/screenshots/SU-B-16-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-16-04.jpg b/screenshots/SU-B-16-04.jpg deleted file mode 100644 index cb331f23..00000000 Binary files a/screenshots/SU-B-16-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-16.jpg b/screenshots/SU-B-16.jpg deleted file mode 100644 index c961b2c6..00000000 Binary files a/screenshots/SU-B-16.jpg and /dev/null differ diff --git a/screenshots/SU-B-17-00.jpg b/screenshots/SU-B-17-00.jpg deleted file mode 100644 index 922c9944..00000000 Binary files a/screenshots/SU-B-17-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-17-01.jpg b/screenshots/SU-B-17-01.jpg deleted file mode 100644 index f6aae5ed..00000000 Binary files a/screenshots/SU-B-17-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-17-02.jpg b/screenshots/SU-B-17-02.jpg deleted file mode 100644 index 34e695ee..00000000 Binary files a/screenshots/SU-B-17-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-17-03.jpg b/screenshots/SU-B-17-03.jpg deleted file mode 100644 index 3999e330..00000000 Binary files a/screenshots/SU-B-17-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-17-04.jpg b/screenshots/SU-B-17-04.jpg deleted file mode 100644 index 6b8a38ac..00000000 Binary files a/screenshots/SU-B-17-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-17.jpg b/screenshots/SU-B-17.jpg deleted file mode 100644 index fd3adaa0..00000000 Binary files a/screenshots/SU-B-17.jpg and /dev/null differ diff --git a/screenshots/SU-B-18-00.jpg b/screenshots/SU-B-18-00.jpg deleted file mode 100644 index 2f11690d..00000000 Binary files a/screenshots/SU-B-18-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-18-01.jpg b/screenshots/SU-B-18-01.jpg deleted file mode 100644 index 1ee0c8e6..00000000 Binary files a/screenshots/SU-B-18-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-18-02.jpg b/screenshots/SU-B-18-02.jpg deleted file mode 100644 index 7e199353..00000000 Binary files a/screenshots/SU-B-18-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-18-03.jpg b/screenshots/SU-B-18-03.jpg deleted file mode 100644 index c77ca07f..00000000 Binary files a/screenshots/SU-B-18-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-18-04.jpg b/screenshots/SU-B-18-04.jpg deleted file mode 100644 index c9fa3d6d..00000000 Binary files a/screenshots/SU-B-18-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-18.jpg b/screenshots/SU-B-18.jpg deleted file mode 100644 index e7a87f21..00000000 Binary files a/screenshots/SU-B-18.jpg and /dev/null differ diff --git a/screenshots/SU-B-19-00.jpg b/screenshots/SU-B-19-00.jpg deleted file mode 100644 index c0f5a522..00000000 Binary files a/screenshots/SU-B-19-00.jpg and /dev/null differ diff --git a/screenshots/SU-B-19-01.jpg b/screenshots/SU-B-19-01.jpg deleted file mode 100644 index d28ee280..00000000 Binary files a/screenshots/SU-B-19-01.jpg and /dev/null differ diff --git a/screenshots/SU-B-19-02.jpg b/screenshots/SU-B-19-02.jpg deleted file mode 100644 index a79a175e..00000000 Binary files a/screenshots/SU-B-19-02.jpg and /dev/null differ diff --git a/screenshots/SU-B-19-03.jpg b/screenshots/SU-B-19-03.jpg deleted file mode 100644 index fcd08233..00000000 Binary files a/screenshots/SU-B-19-03.jpg and /dev/null differ diff --git a/screenshots/SU-B-19-04.jpg b/screenshots/SU-B-19-04.jpg deleted file mode 100644 index dd6d0438..00000000 Binary files a/screenshots/SU-B-19-04.jpg and /dev/null differ diff --git a/screenshots/SU-B-19.jpg b/screenshots/SU-B-19.jpg deleted file mode 100644 index 1af9e5dc..00000000 Binary files a/screenshots/SU-B-19.jpg and /dev/null differ diff --git a/screenshots/SU-C-00-00.jpg b/screenshots/SU-C-00-00.jpg deleted file mode 100644 index f3804628..00000000 Binary files a/screenshots/SU-C-00-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-00-01.jpg b/screenshots/SU-C-00-01.jpg deleted file mode 100644 index 21a3ee48..00000000 Binary files a/screenshots/SU-C-00-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-00-02.jpg b/screenshots/SU-C-00-02.jpg deleted file mode 100644 index 509f5874..00000000 Binary files a/screenshots/SU-C-00-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-00-03.jpg b/screenshots/SU-C-00-03.jpg deleted file mode 100644 index 8819cd71..00000000 Binary files a/screenshots/SU-C-00-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-00-04.jpg b/screenshots/SU-C-00-04.jpg deleted file mode 100644 index 02766712..00000000 Binary files a/screenshots/SU-C-00-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-00.jpg b/screenshots/SU-C-00.jpg deleted file mode 100644 index c5b00ac6..00000000 Binary files a/screenshots/SU-C-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-01-00.jpg b/screenshots/SU-C-01-00.jpg deleted file mode 100644 index c939f2b4..00000000 Binary files a/screenshots/SU-C-01-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-01-01.jpg b/screenshots/SU-C-01-01.jpg deleted file mode 100644 index 00fe2c48..00000000 Binary files a/screenshots/SU-C-01-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-01-02.jpg b/screenshots/SU-C-01-02.jpg deleted file mode 100644 index 74df0ccf..00000000 Binary files a/screenshots/SU-C-01-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-01-03.jpg b/screenshots/SU-C-01-03.jpg deleted file mode 100644 index df0cddf9..00000000 Binary files a/screenshots/SU-C-01-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-01-04.jpg b/screenshots/SU-C-01-04.jpg deleted file mode 100644 index f661998e..00000000 Binary files a/screenshots/SU-C-01-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-01.jpg b/screenshots/SU-C-01.jpg deleted file mode 100644 index 765ee6e2..00000000 Binary files a/screenshots/SU-C-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-02-00.jpg b/screenshots/SU-C-02-00.jpg deleted file mode 100644 index 8de0a600..00000000 Binary files a/screenshots/SU-C-02-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-02-01.jpg b/screenshots/SU-C-02-01.jpg deleted file mode 100644 index f0835ea8..00000000 Binary files a/screenshots/SU-C-02-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-02-02.jpg b/screenshots/SU-C-02-02.jpg deleted file mode 100644 index 5ea7857b..00000000 Binary files a/screenshots/SU-C-02-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-02-03.jpg b/screenshots/SU-C-02-03.jpg deleted file mode 100644 index 72ff0e58..00000000 Binary files a/screenshots/SU-C-02-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-02-04.jpg b/screenshots/SU-C-02-04.jpg deleted file mode 100644 index 75ac3cb5..00000000 Binary files a/screenshots/SU-C-02-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-02.jpg b/screenshots/SU-C-02.jpg deleted file mode 100644 index bdc8ab4a..00000000 Binary files a/screenshots/SU-C-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-03-00.jpg b/screenshots/SU-C-03-00.jpg deleted file mode 100644 index 2f9c28ad..00000000 Binary files a/screenshots/SU-C-03-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-03-01.jpg b/screenshots/SU-C-03-01.jpg deleted file mode 100644 index ea345d59..00000000 Binary files a/screenshots/SU-C-03-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-03-02.jpg b/screenshots/SU-C-03-02.jpg deleted file mode 100644 index bc0daf0a..00000000 Binary files a/screenshots/SU-C-03-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-03-03.jpg b/screenshots/SU-C-03-03.jpg deleted file mode 100644 index c09b9c1e..00000000 Binary files a/screenshots/SU-C-03-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-03-04.jpg b/screenshots/SU-C-03-04.jpg deleted file mode 100644 index bdc5ac81..00000000 Binary files a/screenshots/SU-C-03-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-03.jpg b/screenshots/SU-C-03.jpg deleted file mode 100644 index 572a22b6..00000000 Binary files a/screenshots/SU-C-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-04-00.jpg b/screenshots/SU-C-04-00.jpg deleted file mode 100644 index 8d90e507..00000000 Binary files a/screenshots/SU-C-04-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-04-01.jpg b/screenshots/SU-C-04-01.jpg deleted file mode 100644 index 4bb27484..00000000 Binary files a/screenshots/SU-C-04-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-04-02.jpg b/screenshots/SU-C-04-02.jpg deleted file mode 100644 index 73d2ab8a..00000000 Binary files a/screenshots/SU-C-04-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-04-03.jpg b/screenshots/SU-C-04-03.jpg deleted file mode 100644 index f2b71e1e..00000000 Binary files a/screenshots/SU-C-04-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-04-04.jpg b/screenshots/SU-C-04-04.jpg deleted file mode 100644 index 2242df66..00000000 Binary files a/screenshots/SU-C-04-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-04.jpg b/screenshots/SU-C-04.jpg deleted file mode 100644 index b51c1e00..00000000 Binary files a/screenshots/SU-C-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-05-00.jpg b/screenshots/SU-C-05-00.jpg deleted file mode 100644 index 4d56df0d..00000000 Binary files a/screenshots/SU-C-05-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-05-01.jpg b/screenshots/SU-C-05-01.jpg deleted file mode 100644 index b41a24a8..00000000 Binary files a/screenshots/SU-C-05-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-05-02.jpg b/screenshots/SU-C-05-02.jpg deleted file mode 100644 index 6d1aacba..00000000 Binary files a/screenshots/SU-C-05-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-05-03.jpg b/screenshots/SU-C-05-03.jpg deleted file mode 100644 index 3a7c31a8..00000000 Binary files a/screenshots/SU-C-05-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-05-04.jpg b/screenshots/SU-C-05-04.jpg deleted file mode 100644 index bd30b305..00000000 Binary files a/screenshots/SU-C-05-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-05.jpg b/screenshots/SU-C-05.jpg deleted file mode 100644 index 67096f22..00000000 Binary files a/screenshots/SU-C-05.jpg and /dev/null differ diff --git a/screenshots/SU-C-06-00.jpg b/screenshots/SU-C-06-00.jpg deleted file mode 100644 index e4e56496..00000000 Binary files a/screenshots/SU-C-06-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-06-01.jpg b/screenshots/SU-C-06-01.jpg deleted file mode 100644 index 6c42192f..00000000 Binary files a/screenshots/SU-C-06-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-06-02.jpg b/screenshots/SU-C-06-02.jpg deleted file mode 100644 index 3a9640cb..00000000 Binary files a/screenshots/SU-C-06-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-06-03.jpg b/screenshots/SU-C-06-03.jpg deleted file mode 100644 index a24ed3ed..00000000 Binary files a/screenshots/SU-C-06-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-06-04.jpg b/screenshots/SU-C-06-04.jpg deleted file mode 100644 index 7388a3d1..00000000 Binary files a/screenshots/SU-C-06-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-06.jpg b/screenshots/SU-C-06.jpg deleted file mode 100644 index 714bbb3a..00000000 Binary files a/screenshots/SU-C-06.jpg and /dev/null differ diff --git a/screenshots/SU-C-07-00.jpg b/screenshots/SU-C-07-00.jpg deleted file mode 100644 index e26026d0..00000000 Binary files a/screenshots/SU-C-07-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-07-01.jpg b/screenshots/SU-C-07-01.jpg deleted file mode 100644 index 4eefd7b0..00000000 Binary files a/screenshots/SU-C-07-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-07-02.jpg b/screenshots/SU-C-07-02.jpg deleted file mode 100644 index 7093093a..00000000 Binary files a/screenshots/SU-C-07-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-07-03.jpg b/screenshots/SU-C-07-03.jpg deleted file mode 100644 index 5828d925..00000000 Binary files a/screenshots/SU-C-07-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-07-04.jpg b/screenshots/SU-C-07-04.jpg deleted file mode 100644 index 72bf456d..00000000 Binary files a/screenshots/SU-C-07-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-07.jpg b/screenshots/SU-C-07.jpg deleted file mode 100644 index fa47f9c2..00000000 Binary files a/screenshots/SU-C-07.jpg and /dev/null differ diff --git a/screenshots/SU-C-08-00.jpg b/screenshots/SU-C-08-00.jpg deleted file mode 100644 index fe8f9e1e..00000000 Binary files a/screenshots/SU-C-08-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-08-01.jpg b/screenshots/SU-C-08-01.jpg deleted file mode 100644 index 810cb657..00000000 Binary files a/screenshots/SU-C-08-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-08-02.jpg b/screenshots/SU-C-08-02.jpg deleted file mode 100644 index 7dce4c51..00000000 Binary files a/screenshots/SU-C-08-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-08-03.jpg b/screenshots/SU-C-08-03.jpg deleted file mode 100644 index a04f0c5d..00000000 Binary files a/screenshots/SU-C-08-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-08-04.jpg b/screenshots/SU-C-08-04.jpg deleted file mode 100644 index d78aa5f7..00000000 Binary files a/screenshots/SU-C-08-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-08.jpg b/screenshots/SU-C-08.jpg deleted file mode 100644 index 15296efb..00000000 Binary files a/screenshots/SU-C-08.jpg and /dev/null differ diff --git a/screenshots/SU-C-09-00.jpg b/screenshots/SU-C-09-00.jpg deleted file mode 100644 index 768cfbe5..00000000 Binary files a/screenshots/SU-C-09-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-09-01.jpg b/screenshots/SU-C-09-01.jpg deleted file mode 100644 index 50489693..00000000 Binary files a/screenshots/SU-C-09-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-09-02.jpg b/screenshots/SU-C-09-02.jpg deleted file mode 100644 index 6a7fa36b..00000000 Binary files a/screenshots/SU-C-09-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-09-03.jpg b/screenshots/SU-C-09-03.jpg deleted file mode 100644 index 1960ec75..00000000 Binary files a/screenshots/SU-C-09-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-09-04.jpg b/screenshots/SU-C-09-04.jpg deleted file mode 100644 index e9e346a0..00000000 Binary files a/screenshots/SU-C-09-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-09.jpg b/screenshots/SU-C-09.jpg deleted file mode 100644 index 2b39a663..00000000 Binary files a/screenshots/SU-C-09.jpg and /dev/null differ diff --git a/screenshots/SU-C-10-00.jpg b/screenshots/SU-C-10-00.jpg deleted file mode 100644 index 4ae66e93..00000000 Binary files a/screenshots/SU-C-10-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-10-01.jpg b/screenshots/SU-C-10-01.jpg deleted file mode 100644 index 59d6cd02..00000000 Binary files a/screenshots/SU-C-10-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-10-02.jpg b/screenshots/SU-C-10-02.jpg deleted file mode 100644 index 110f2cd2..00000000 Binary files a/screenshots/SU-C-10-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-10-03.jpg b/screenshots/SU-C-10-03.jpg deleted file mode 100644 index 9c178bd2..00000000 Binary files a/screenshots/SU-C-10-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-10-04.jpg b/screenshots/SU-C-10-04.jpg deleted file mode 100644 index b9a6613f..00000000 Binary files a/screenshots/SU-C-10-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-10.jpg b/screenshots/SU-C-10.jpg deleted file mode 100644 index 3946dc7f..00000000 Binary files a/screenshots/SU-C-10.jpg and /dev/null differ diff --git a/screenshots/SU-C-11-00.jpg b/screenshots/SU-C-11-00.jpg deleted file mode 100644 index ff4daf28..00000000 Binary files a/screenshots/SU-C-11-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-11-01.jpg b/screenshots/SU-C-11-01.jpg deleted file mode 100644 index 6beadd3a..00000000 Binary files a/screenshots/SU-C-11-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-11-02.jpg b/screenshots/SU-C-11-02.jpg deleted file mode 100644 index d063c47d..00000000 Binary files a/screenshots/SU-C-11-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-11-03.jpg b/screenshots/SU-C-11-03.jpg deleted file mode 100644 index d2b08a1d..00000000 Binary files a/screenshots/SU-C-11-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-11-04.jpg b/screenshots/SU-C-11-04.jpg deleted file mode 100644 index 2c9b0614..00000000 Binary files a/screenshots/SU-C-11-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-11.jpg b/screenshots/SU-C-11.jpg deleted file mode 100644 index d8a61f05..00000000 Binary files a/screenshots/SU-C-11.jpg and /dev/null differ diff --git a/screenshots/SU-C-12-00.jpg b/screenshots/SU-C-12-00.jpg deleted file mode 100644 index 07c36287..00000000 Binary files a/screenshots/SU-C-12-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-12-01.jpg b/screenshots/SU-C-12-01.jpg deleted file mode 100644 index 548bf0b3..00000000 Binary files a/screenshots/SU-C-12-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-12-02.jpg b/screenshots/SU-C-12-02.jpg deleted file mode 100644 index ed086bb7..00000000 Binary files a/screenshots/SU-C-12-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-12-03.jpg b/screenshots/SU-C-12-03.jpg deleted file mode 100644 index 3f236e51..00000000 Binary files a/screenshots/SU-C-12-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-12-04.jpg b/screenshots/SU-C-12-04.jpg deleted file mode 100644 index 4577cdd4..00000000 Binary files a/screenshots/SU-C-12-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-12.jpg b/screenshots/SU-C-12.jpg deleted file mode 100644 index 011c43e6..00000000 Binary files a/screenshots/SU-C-12.jpg and /dev/null differ diff --git a/screenshots/SU-C-13-00.jpg b/screenshots/SU-C-13-00.jpg deleted file mode 100644 index 82943591..00000000 Binary files a/screenshots/SU-C-13-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-13-01.jpg b/screenshots/SU-C-13-01.jpg deleted file mode 100644 index 6792be54..00000000 Binary files a/screenshots/SU-C-13-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-13-02.jpg b/screenshots/SU-C-13-02.jpg deleted file mode 100644 index 72a8e4d2..00000000 Binary files a/screenshots/SU-C-13-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-13-03.jpg b/screenshots/SU-C-13-03.jpg deleted file mode 100644 index 7d01ac4d..00000000 Binary files a/screenshots/SU-C-13-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-13-04.jpg b/screenshots/SU-C-13-04.jpg deleted file mode 100644 index 23a7ecdd..00000000 Binary files a/screenshots/SU-C-13-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-13.jpg b/screenshots/SU-C-13.jpg deleted file mode 100644 index 0f5587fa..00000000 Binary files a/screenshots/SU-C-13.jpg and /dev/null differ diff --git a/screenshots/SU-C-14-00.jpg b/screenshots/SU-C-14-00.jpg deleted file mode 100644 index 3b917364..00000000 Binary files a/screenshots/SU-C-14-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-14-01.jpg b/screenshots/SU-C-14-01.jpg deleted file mode 100644 index d9a03e2a..00000000 Binary files a/screenshots/SU-C-14-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-14-02.jpg b/screenshots/SU-C-14-02.jpg deleted file mode 100644 index bf4e1b20..00000000 Binary files a/screenshots/SU-C-14-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-14-03.jpg b/screenshots/SU-C-14-03.jpg deleted file mode 100644 index 58d82b32..00000000 Binary files a/screenshots/SU-C-14-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-14-04.jpg b/screenshots/SU-C-14-04.jpg deleted file mode 100644 index 2c1e44c5..00000000 Binary files a/screenshots/SU-C-14-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-14.jpg b/screenshots/SU-C-14.jpg deleted file mode 100644 index a4efac36..00000000 Binary files a/screenshots/SU-C-14.jpg and /dev/null differ diff --git a/screenshots/SU-C-15-00.jpg b/screenshots/SU-C-15-00.jpg deleted file mode 100644 index 8f4fd60d..00000000 Binary files a/screenshots/SU-C-15-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-15-01.jpg b/screenshots/SU-C-15-01.jpg deleted file mode 100644 index 0e7048b9..00000000 Binary files a/screenshots/SU-C-15-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-15-02.jpg b/screenshots/SU-C-15-02.jpg deleted file mode 100644 index 4765088b..00000000 Binary files a/screenshots/SU-C-15-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-15-03.jpg b/screenshots/SU-C-15-03.jpg deleted file mode 100644 index d857cb97..00000000 Binary files a/screenshots/SU-C-15-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-15-04.jpg b/screenshots/SU-C-15-04.jpg deleted file mode 100644 index 7236c54f..00000000 Binary files a/screenshots/SU-C-15-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-15.jpg b/screenshots/SU-C-15.jpg deleted file mode 100644 index 59d9420e..00000000 Binary files a/screenshots/SU-C-15.jpg and /dev/null differ diff --git a/screenshots/SU-C-16-00.jpg b/screenshots/SU-C-16-00.jpg deleted file mode 100644 index d1dd0627..00000000 Binary files a/screenshots/SU-C-16-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-16-01.jpg b/screenshots/SU-C-16-01.jpg deleted file mode 100644 index f555458e..00000000 Binary files a/screenshots/SU-C-16-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-16-02.jpg b/screenshots/SU-C-16-02.jpg deleted file mode 100644 index 3c32fe1d..00000000 Binary files a/screenshots/SU-C-16-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-16-03.jpg b/screenshots/SU-C-16-03.jpg deleted file mode 100644 index a5ef302d..00000000 Binary files a/screenshots/SU-C-16-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-16-04.jpg b/screenshots/SU-C-16-04.jpg deleted file mode 100644 index ee782cb9..00000000 Binary files a/screenshots/SU-C-16-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-16.jpg b/screenshots/SU-C-16.jpg deleted file mode 100644 index 4c8826d3..00000000 Binary files a/screenshots/SU-C-16.jpg and /dev/null differ diff --git a/screenshots/SU-C-17-00.jpg b/screenshots/SU-C-17-00.jpg deleted file mode 100644 index 69e8d00b..00000000 Binary files a/screenshots/SU-C-17-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-17-01.jpg b/screenshots/SU-C-17-01.jpg deleted file mode 100644 index 4cc2655a..00000000 Binary files a/screenshots/SU-C-17-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-17-02.jpg b/screenshots/SU-C-17-02.jpg deleted file mode 100644 index bfc80609..00000000 Binary files a/screenshots/SU-C-17-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-17-03.jpg b/screenshots/SU-C-17-03.jpg deleted file mode 100644 index 937afa59..00000000 Binary files a/screenshots/SU-C-17-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-17-04.jpg b/screenshots/SU-C-17-04.jpg deleted file mode 100644 index 1779d4a2..00000000 Binary files a/screenshots/SU-C-17-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-17.jpg b/screenshots/SU-C-17.jpg deleted file mode 100644 index e82e64b7..00000000 Binary files a/screenshots/SU-C-17.jpg and /dev/null differ diff --git a/screenshots/SU-C-18-00.jpg b/screenshots/SU-C-18-00.jpg deleted file mode 100644 index ea0e44c1..00000000 Binary files a/screenshots/SU-C-18-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-18-01.jpg b/screenshots/SU-C-18-01.jpg deleted file mode 100644 index 19e33098..00000000 Binary files a/screenshots/SU-C-18-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-18-02.jpg b/screenshots/SU-C-18-02.jpg deleted file mode 100644 index dfbb7930..00000000 Binary files a/screenshots/SU-C-18-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-18-03.jpg b/screenshots/SU-C-18-03.jpg deleted file mode 100644 index ee0c9da7..00000000 Binary files a/screenshots/SU-C-18-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-18-04.jpg b/screenshots/SU-C-18-04.jpg deleted file mode 100644 index d16503c2..00000000 Binary files a/screenshots/SU-C-18-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-18.jpg b/screenshots/SU-C-18.jpg deleted file mode 100644 index 0388aba9..00000000 Binary files a/screenshots/SU-C-18.jpg and /dev/null differ diff --git a/screenshots/SU-C-19-00.jpg b/screenshots/SU-C-19-00.jpg deleted file mode 100644 index 048ba71a..00000000 Binary files a/screenshots/SU-C-19-00.jpg and /dev/null differ diff --git a/screenshots/SU-C-19-01.jpg b/screenshots/SU-C-19-01.jpg deleted file mode 100644 index 45eea4d1..00000000 Binary files a/screenshots/SU-C-19-01.jpg and /dev/null differ diff --git a/screenshots/SU-C-19-02.jpg b/screenshots/SU-C-19-02.jpg deleted file mode 100644 index 41781794..00000000 Binary files a/screenshots/SU-C-19-02.jpg and /dev/null differ diff --git a/screenshots/SU-C-19-03.jpg b/screenshots/SU-C-19-03.jpg deleted file mode 100644 index 49b8cc7e..00000000 Binary files a/screenshots/SU-C-19-03.jpg and /dev/null differ diff --git a/screenshots/SU-C-19-04.jpg b/screenshots/SU-C-19-04.jpg deleted file mode 100644 index 4a9425a0..00000000 Binary files a/screenshots/SU-C-19-04.jpg and /dev/null differ diff --git a/screenshots/SU-C-19.jpg b/screenshots/SU-C-19.jpg deleted file mode 100644 index e0f50624..00000000 Binary files a/screenshots/SU-C-19.jpg and /dev/null differ diff --git a/screenshots/SU-D-00-00.jpg b/screenshots/SU-D-00-00.jpg deleted file mode 100644 index 75cebf67..00000000 Binary files a/screenshots/SU-D-00-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-00-01.jpg b/screenshots/SU-D-00-01.jpg deleted file mode 100644 index ae14f8d9..00000000 Binary files a/screenshots/SU-D-00-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-00-02.jpg b/screenshots/SU-D-00-02.jpg deleted file mode 100644 index 744e0be6..00000000 Binary files a/screenshots/SU-D-00-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-00-03.jpg b/screenshots/SU-D-00-03.jpg deleted file mode 100644 index eef50775..00000000 Binary files a/screenshots/SU-D-00-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-00-04.jpg b/screenshots/SU-D-00-04.jpg deleted file mode 100644 index d94b0f34..00000000 Binary files a/screenshots/SU-D-00-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-00.jpg b/screenshots/SU-D-00.jpg deleted file mode 100644 index 710a4db8..00000000 Binary files a/screenshots/SU-D-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-01-00.jpg b/screenshots/SU-D-01-00.jpg deleted file mode 100644 index a3a18caa..00000000 Binary files a/screenshots/SU-D-01-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-01-01.jpg b/screenshots/SU-D-01-01.jpg deleted file mode 100644 index 1a934101..00000000 Binary files a/screenshots/SU-D-01-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-01-02.jpg b/screenshots/SU-D-01-02.jpg deleted file mode 100644 index 57749dae..00000000 Binary files a/screenshots/SU-D-01-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-01-03.jpg b/screenshots/SU-D-01-03.jpg deleted file mode 100644 index dc8f6072..00000000 Binary files a/screenshots/SU-D-01-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-01-04.jpg b/screenshots/SU-D-01-04.jpg deleted file mode 100644 index 00c83ddb..00000000 Binary files a/screenshots/SU-D-01-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-01.jpg b/screenshots/SU-D-01.jpg deleted file mode 100644 index 59e119ec..00000000 Binary files a/screenshots/SU-D-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-02-00.jpg b/screenshots/SU-D-02-00.jpg deleted file mode 100644 index b05c3f71..00000000 Binary files a/screenshots/SU-D-02-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-02-01.jpg b/screenshots/SU-D-02-01.jpg deleted file mode 100644 index aa004cac..00000000 Binary files a/screenshots/SU-D-02-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-02-02.jpg b/screenshots/SU-D-02-02.jpg deleted file mode 100644 index dcee8d25..00000000 Binary files a/screenshots/SU-D-02-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-02-03.jpg b/screenshots/SU-D-02-03.jpg deleted file mode 100644 index 96823f58..00000000 Binary files a/screenshots/SU-D-02-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-02-04.jpg b/screenshots/SU-D-02-04.jpg deleted file mode 100644 index 5dc760bc..00000000 Binary files a/screenshots/SU-D-02-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-02.jpg b/screenshots/SU-D-02.jpg deleted file mode 100644 index 838cc370..00000000 Binary files a/screenshots/SU-D-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-03-00.jpg b/screenshots/SU-D-03-00.jpg deleted file mode 100644 index 4f8d652f..00000000 Binary files a/screenshots/SU-D-03-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-03-01.jpg b/screenshots/SU-D-03-01.jpg deleted file mode 100644 index 7c7c6df4..00000000 Binary files a/screenshots/SU-D-03-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-03-02.jpg b/screenshots/SU-D-03-02.jpg deleted file mode 100644 index 5db0e9d8..00000000 Binary files a/screenshots/SU-D-03-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-03-03.jpg b/screenshots/SU-D-03-03.jpg deleted file mode 100644 index 15284ba7..00000000 Binary files a/screenshots/SU-D-03-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-03-04.jpg b/screenshots/SU-D-03-04.jpg deleted file mode 100644 index 5c8441ed..00000000 Binary files a/screenshots/SU-D-03-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-03.jpg b/screenshots/SU-D-03.jpg deleted file mode 100644 index 0fbf4f39..00000000 Binary files a/screenshots/SU-D-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-04-00.jpg b/screenshots/SU-D-04-00.jpg deleted file mode 100644 index 7f817d52..00000000 Binary files a/screenshots/SU-D-04-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-04-01.jpg b/screenshots/SU-D-04-01.jpg deleted file mode 100644 index 6d177dbc..00000000 Binary files a/screenshots/SU-D-04-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-04-02.jpg b/screenshots/SU-D-04-02.jpg deleted file mode 100644 index e07c9085..00000000 Binary files a/screenshots/SU-D-04-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-04-03.jpg b/screenshots/SU-D-04-03.jpg deleted file mode 100644 index d4c4f22d..00000000 Binary files a/screenshots/SU-D-04-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-04-04.jpg b/screenshots/SU-D-04-04.jpg deleted file mode 100644 index a0cd967a..00000000 Binary files a/screenshots/SU-D-04-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-04.jpg b/screenshots/SU-D-04.jpg deleted file mode 100644 index a6ff42fa..00000000 Binary files a/screenshots/SU-D-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-05-00.jpg b/screenshots/SU-D-05-00.jpg deleted file mode 100644 index 27295147..00000000 Binary files a/screenshots/SU-D-05-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-05-01.jpg b/screenshots/SU-D-05-01.jpg deleted file mode 100644 index 5b7de109..00000000 Binary files a/screenshots/SU-D-05-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-05-02.jpg b/screenshots/SU-D-05-02.jpg deleted file mode 100644 index 80473a73..00000000 Binary files a/screenshots/SU-D-05-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-05-03.jpg b/screenshots/SU-D-05-03.jpg deleted file mode 100644 index 119a6541..00000000 Binary files a/screenshots/SU-D-05-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-05-04.jpg b/screenshots/SU-D-05-04.jpg deleted file mode 100644 index 9b249de8..00000000 Binary files a/screenshots/SU-D-05-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-05.jpg b/screenshots/SU-D-05.jpg deleted file mode 100644 index aafa385c..00000000 Binary files a/screenshots/SU-D-05.jpg and /dev/null differ diff --git a/screenshots/SU-D-06-00.jpg b/screenshots/SU-D-06-00.jpg deleted file mode 100644 index 17b7039c..00000000 Binary files a/screenshots/SU-D-06-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-06-01.jpg b/screenshots/SU-D-06-01.jpg deleted file mode 100644 index 8e4e1990..00000000 Binary files a/screenshots/SU-D-06-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-06-02.jpg b/screenshots/SU-D-06-02.jpg deleted file mode 100644 index cde3cdf2..00000000 Binary files a/screenshots/SU-D-06-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-06-03.jpg b/screenshots/SU-D-06-03.jpg deleted file mode 100644 index acf98b9a..00000000 Binary files a/screenshots/SU-D-06-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-06-04.jpg b/screenshots/SU-D-06-04.jpg deleted file mode 100644 index f3393f80..00000000 Binary files a/screenshots/SU-D-06-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-06.jpg b/screenshots/SU-D-06.jpg deleted file mode 100644 index 4476be68..00000000 Binary files a/screenshots/SU-D-06.jpg and /dev/null differ diff --git a/screenshots/SU-D-07-00.jpg b/screenshots/SU-D-07-00.jpg deleted file mode 100644 index 0c2246ce..00000000 Binary files a/screenshots/SU-D-07-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-07-01.jpg b/screenshots/SU-D-07-01.jpg deleted file mode 100644 index b9f32cfa..00000000 Binary files a/screenshots/SU-D-07-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-07-02.jpg b/screenshots/SU-D-07-02.jpg deleted file mode 100644 index b138842b..00000000 Binary files a/screenshots/SU-D-07-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-07-03.jpg b/screenshots/SU-D-07-03.jpg deleted file mode 100644 index b361781b..00000000 Binary files a/screenshots/SU-D-07-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-07-04.jpg b/screenshots/SU-D-07-04.jpg deleted file mode 100644 index 5adb20e0..00000000 Binary files a/screenshots/SU-D-07-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-07.jpg b/screenshots/SU-D-07.jpg deleted file mode 100644 index 4d70cb1f..00000000 Binary files a/screenshots/SU-D-07.jpg and /dev/null differ diff --git a/screenshots/SU-D-08-00.jpg b/screenshots/SU-D-08-00.jpg deleted file mode 100644 index e35a3a67..00000000 Binary files a/screenshots/SU-D-08-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-08-01.jpg b/screenshots/SU-D-08-01.jpg deleted file mode 100644 index 001481a7..00000000 Binary files a/screenshots/SU-D-08-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-08-02.jpg b/screenshots/SU-D-08-02.jpg deleted file mode 100644 index 8e7bd88e..00000000 Binary files a/screenshots/SU-D-08-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-08-03.jpg b/screenshots/SU-D-08-03.jpg deleted file mode 100644 index 1d4540d5..00000000 Binary files a/screenshots/SU-D-08-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-08-04.jpg b/screenshots/SU-D-08-04.jpg deleted file mode 100644 index 72f726da..00000000 Binary files a/screenshots/SU-D-08-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-08.jpg b/screenshots/SU-D-08.jpg deleted file mode 100644 index c910e845..00000000 Binary files a/screenshots/SU-D-08.jpg and /dev/null differ diff --git a/screenshots/SU-D-09-00.jpg b/screenshots/SU-D-09-00.jpg deleted file mode 100644 index 7a96779c..00000000 Binary files a/screenshots/SU-D-09-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-09-01.jpg b/screenshots/SU-D-09-01.jpg deleted file mode 100644 index 915ca2f2..00000000 Binary files a/screenshots/SU-D-09-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-09-02.jpg b/screenshots/SU-D-09-02.jpg deleted file mode 100644 index 237c9118..00000000 Binary files a/screenshots/SU-D-09-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-09-03.jpg b/screenshots/SU-D-09-03.jpg deleted file mode 100644 index 3bb07bdd..00000000 Binary files a/screenshots/SU-D-09-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-09-04.jpg b/screenshots/SU-D-09-04.jpg deleted file mode 100644 index 2ee7ab67..00000000 Binary files a/screenshots/SU-D-09-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-09.jpg b/screenshots/SU-D-09.jpg deleted file mode 100644 index 101447f3..00000000 Binary files a/screenshots/SU-D-09.jpg and /dev/null differ diff --git a/screenshots/SU-D-10-00.jpg b/screenshots/SU-D-10-00.jpg deleted file mode 100644 index 93ba8a49..00000000 Binary files a/screenshots/SU-D-10-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-10-01.jpg b/screenshots/SU-D-10-01.jpg deleted file mode 100644 index 1c9f1d36..00000000 Binary files a/screenshots/SU-D-10-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-10-02.jpg b/screenshots/SU-D-10-02.jpg deleted file mode 100644 index e2b4fd07..00000000 Binary files a/screenshots/SU-D-10-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-10-03.jpg b/screenshots/SU-D-10-03.jpg deleted file mode 100644 index 6337e346..00000000 Binary files a/screenshots/SU-D-10-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-10-04.jpg b/screenshots/SU-D-10-04.jpg deleted file mode 100644 index 9bacef36..00000000 Binary files a/screenshots/SU-D-10-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-10.jpg b/screenshots/SU-D-10.jpg deleted file mode 100644 index f94a235a..00000000 Binary files a/screenshots/SU-D-10.jpg and /dev/null differ diff --git a/screenshots/SU-D-11-00.jpg b/screenshots/SU-D-11-00.jpg deleted file mode 100644 index aa58817b..00000000 Binary files a/screenshots/SU-D-11-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-11-01.jpg b/screenshots/SU-D-11-01.jpg deleted file mode 100644 index 802b721c..00000000 Binary files a/screenshots/SU-D-11-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-11-02.jpg b/screenshots/SU-D-11-02.jpg deleted file mode 100644 index 5b2010e0..00000000 Binary files a/screenshots/SU-D-11-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-11-03.jpg b/screenshots/SU-D-11-03.jpg deleted file mode 100644 index ae6ef371..00000000 Binary files a/screenshots/SU-D-11-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-11-04.jpg b/screenshots/SU-D-11-04.jpg deleted file mode 100644 index f5da113f..00000000 Binary files a/screenshots/SU-D-11-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-11.jpg b/screenshots/SU-D-11.jpg deleted file mode 100644 index 582bd9e1..00000000 Binary files a/screenshots/SU-D-11.jpg and /dev/null differ diff --git a/screenshots/SU-D-12-00.jpg b/screenshots/SU-D-12-00.jpg deleted file mode 100644 index 21ce70ad..00000000 Binary files a/screenshots/SU-D-12-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-12-01.jpg b/screenshots/SU-D-12-01.jpg deleted file mode 100644 index ba15fa43..00000000 Binary files a/screenshots/SU-D-12-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-12-02.jpg b/screenshots/SU-D-12-02.jpg deleted file mode 100644 index 1dd00883..00000000 Binary files a/screenshots/SU-D-12-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-12-03.jpg b/screenshots/SU-D-12-03.jpg deleted file mode 100644 index bf2daa30..00000000 Binary files a/screenshots/SU-D-12-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-12-04.jpg b/screenshots/SU-D-12-04.jpg deleted file mode 100644 index ddb1aed2..00000000 Binary files a/screenshots/SU-D-12-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-12.jpg b/screenshots/SU-D-12.jpg deleted file mode 100644 index 6e97c6ed..00000000 Binary files a/screenshots/SU-D-12.jpg and /dev/null differ diff --git a/screenshots/SU-D-13-00.jpg b/screenshots/SU-D-13-00.jpg deleted file mode 100644 index e8128a22..00000000 Binary files a/screenshots/SU-D-13-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-13-01.jpg b/screenshots/SU-D-13-01.jpg deleted file mode 100644 index 29d2959f..00000000 Binary files a/screenshots/SU-D-13-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-13-02.jpg b/screenshots/SU-D-13-02.jpg deleted file mode 100644 index d532eb15..00000000 Binary files a/screenshots/SU-D-13-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-13-03.jpg b/screenshots/SU-D-13-03.jpg deleted file mode 100644 index 05105cfb..00000000 Binary files a/screenshots/SU-D-13-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-13-04.jpg b/screenshots/SU-D-13-04.jpg deleted file mode 100644 index b32baef6..00000000 Binary files a/screenshots/SU-D-13-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-13.jpg b/screenshots/SU-D-13.jpg deleted file mode 100644 index a3f88c0d..00000000 Binary files a/screenshots/SU-D-13.jpg and /dev/null differ diff --git a/screenshots/SU-D-14-00.jpg b/screenshots/SU-D-14-00.jpg deleted file mode 100644 index 343ee288..00000000 Binary files a/screenshots/SU-D-14-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-14-01.jpg b/screenshots/SU-D-14-01.jpg deleted file mode 100644 index 0a0e7a90..00000000 Binary files a/screenshots/SU-D-14-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-14-02.jpg b/screenshots/SU-D-14-02.jpg deleted file mode 100644 index b7d85ca9..00000000 Binary files a/screenshots/SU-D-14-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-14-03.jpg b/screenshots/SU-D-14-03.jpg deleted file mode 100644 index 591f6194..00000000 Binary files a/screenshots/SU-D-14-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-14-04.jpg b/screenshots/SU-D-14-04.jpg deleted file mode 100644 index e499d682..00000000 Binary files a/screenshots/SU-D-14-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-14.jpg b/screenshots/SU-D-14.jpg deleted file mode 100644 index 69ef129a..00000000 Binary files a/screenshots/SU-D-14.jpg and /dev/null differ diff --git a/screenshots/SU-D-15-00.jpg b/screenshots/SU-D-15-00.jpg deleted file mode 100644 index 0eed78ed..00000000 Binary files a/screenshots/SU-D-15-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-15-01.jpg b/screenshots/SU-D-15-01.jpg deleted file mode 100644 index 0ebcd26a..00000000 Binary files a/screenshots/SU-D-15-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-15-02.jpg b/screenshots/SU-D-15-02.jpg deleted file mode 100644 index c9e27894..00000000 Binary files a/screenshots/SU-D-15-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-15-03.jpg b/screenshots/SU-D-15-03.jpg deleted file mode 100644 index 16c0de34..00000000 Binary files a/screenshots/SU-D-15-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-15-04.jpg b/screenshots/SU-D-15-04.jpg deleted file mode 100644 index df186077..00000000 Binary files a/screenshots/SU-D-15-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-15.jpg b/screenshots/SU-D-15.jpg deleted file mode 100644 index 377137ff..00000000 Binary files a/screenshots/SU-D-15.jpg and /dev/null differ diff --git a/screenshots/SU-D-16-00.jpg b/screenshots/SU-D-16-00.jpg deleted file mode 100644 index 9a241a4f..00000000 Binary files a/screenshots/SU-D-16-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-16-01.jpg b/screenshots/SU-D-16-01.jpg deleted file mode 100644 index 56f967ad..00000000 Binary files a/screenshots/SU-D-16-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-16-02.jpg b/screenshots/SU-D-16-02.jpg deleted file mode 100644 index 29fcfefa..00000000 Binary files a/screenshots/SU-D-16-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-16-03.jpg b/screenshots/SU-D-16-03.jpg deleted file mode 100644 index 61785ee7..00000000 Binary files a/screenshots/SU-D-16-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-16-04.jpg b/screenshots/SU-D-16-04.jpg deleted file mode 100644 index 93fe57d6..00000000 Binary files a/screenshots/SU-D-16-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-16.jpg b/screenshots/SU-D-16.jpg deleted file mode 100644 index 3e5dbf94..00000000 Binary files a/screenshots/SU-D-16.jpg and /dev/null differ diff --git a/screenshots/SU-D-17-00.jpg b/screenshots/SU-D-17-00.jpg deleted file mode 100644 index 715e1da0..00000000 Binary files a/screenshots/SU-D-17-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-17-01.jpg b/screenshots/SU-D-17-01.jpg deleted file mode 100644 index 30ecb756..00000000 Binary files a/screenshots/SU-D-17-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-17-02.jpg b/screenshots/SU-D-17-02.jpg deleted file mode 100644 index 9e7163e3..00000000 Binary files a/screenshots/SU-D-17-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-17-03.jpg b/screenshots/SU-D-17-03.jpg deleted file mode 100644 index ce4b6988..00000000 Binary files a/screenshots/SU-D-17-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-17-04.jpg b/screenshots/SU-D-17-04.jpg deleted file mode 100644 index 90e0ed20..00000000 Binary files a/screenshots/SU-D-17-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-17.jpg b/screenshots/SU-D-17.jpg deleted file mode 100644 index 67b9124c..00000000 Binary files a/screenshots/SU-D-17.jpg and /dev/null differ diff --git a/screenshots/SU-D-18-00.jpg b/screenshots/SU-D-18-00.jpg deleted file mode 100644 index e176e6c9..00000000 Binary files a/screenshots/SU-D-18-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-18-01.jpg b/screenshots/SU-D-18-01.jpg deleted file mode 100644 index 0efc7479..00000000 Binary files a/screenshots/SU-D-18-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-18-02.jpg b/screenshots/SU-D-18-02.jpg deleted file mode 100644 index c8c787d8..00000000 Binary files a/screenshots/SU-D-18-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-18-03.jpg b/screenshots/SU-D-18-03.jpg deleted file mode 100644 index 3fd3ed45..00000000 Binary files a/screenshots/SU-D-18-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-18-04.jpg b/screenshots/SU-D-18-04.jpg deleted file mode 100644 index 2a98ed04..00000000 Binary files a/screenshots/SU-D-18-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-18.jpg b/screenshots/SU-D-18.jpg deleted file mode 100644 index 37b7a2ff..00000000 Binary files a/screenshots/SU-D-18.jpg and /dev/null differ diff --git a/screenshots/SU-D-19-00.jpg b/screenshots/SU-D-19-00.jpg deleted file mode 100644 index 35aa2edb..00000000 Binary files a/screenshots/SU-D-19-00.jpg and /dev/null differ diff --git a/screenshots/SU-D-19-01.jpg b/screenshots/SU-D-19-01.jpg deleted file mode 100644 index 8e07c3cf..00000000 Binary files a/screenshots/SU-D-19-01.jpg and /dev/null differ diff --git a/screenshots/SU-D-19-02.jpg b/screenshots/SU-D-19-02.jpg deleted file mode 100644 index 6a1b3b89..00000000 Binary files a/screenshots/SU-D-19-02.jpg and /dev/null differ diff --git a/screenshots/SU-D-19-03.jpg b/screenshots/SU-D-19-03.jpg deleted file mode 100644 index ad7ee67f..00000000 Binary files a/screenshots/SU-D-19-03.jpg and /dev/null differ diff --git a/screenshots/SU-D-19-04.jpg b/screenshots/SU-D-19-04.jpg deleted file mode 100644 index d6fd35cb..00000000 Binary files a/screenshots/SU-D-19-04.jpg and /dev/null differ diff --git a/screenshots/SU-D-19.jpg b/screenshots/SU-D-19.jpg deleted file mode 100644 index 9c861855..00000000 Binary files a/screenshots/SU-D-19.jpg and /dev/null differ diff --git a/screenshots/SU-E-00-00.jpg b/screenshots/SU-E-00-00.jpg deleted file mode 100644 index 9db6f665..00000000 Binary files a/screenshots/SU-E-00-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-00-01.jpg b/screenshots/SU-E-00-01.jpg deleted file mode 100644 index 13e43ca4..00000000 Binary files a/screenshots/SU-E-00-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-00-02.jpg b/screenshots/SU-E-00-02.jpg deleted file mode 100644 index 77d9ee89..00000000 Binary files a/screenshots/SU-E-00-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-00-03.jpg b/screenshots/SU-E-00-03.jpg deleted file mode 100644 index d48aca0f..00000000 Binary files a/screenshots/SU-E-00-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-00-04.jpg b/screenshots/SU-E-00-04.jpg deleted file mode 100644 index 41cb5c54..00000000 Binary files a/screenshots/SU-E-00-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-00.jpg b/screenshots/SU-E-00.jpg deleted file mode 100644 index 73352803..00000000 Binary files a/screenshots/SU-E-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-01-00.jpg b/screenshots/SU-E-01-00.jpg deleted file mode 100644 index 5e3314c8..00000000 Binary files a/screenshots/SU-E-01-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-01-01.jpg b/screenshots/SU-E-01-01.jpg deleted file mode 100644 index c60c3c53..00000000 Binary files a/screenshots/SU-E-01-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-01-02.jpg b/screenshots/SU-E-01-02.jpg deleted file mode 100644 index e7b19916..00000000 Binary files a/screenshots/SU-E-01-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-01-03.jpg b/screenshots/SU-E-01-03.jpg deleted file mode 100644 index d21994b1..00000000 Binary files a/screenshots/SU-E-01-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-01-04.jpg b/screenshots/SU-E-01-04.jpg deleted file mode 100644 index 6cea14eb..00000000 Binary files a/screenshots/SU-E-01-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-01.jpg b/screenshots/SU-E-01.jpg deleted file mode 100644 index b2813202..00000000 Binary files a/screenshots/SU-E-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-02-00.jpg b/screenshots/SU-E-02-00.jpg deleted file mode 100644 index c01772c4..00000000 Binary files a/screenshots/SU-E-02-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-02-01.jpg b/screenshots/SU-E-02-01.jpg deleted file mode 100644 index 758b516f..00000000 Binary files a/screenshots/SU-E-02-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-02-02.jpg b/screenshots/SU-E-02-02.jpg deleted file mode 100644 index 9edd95a7..00000000 Binary files a/screenshots/SU-E-02-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-02-03.jpg b/screenshots/SU-E-02-03.jpg deleted file mode 100644 index f09eca36..00000000 Binary files a/screenshots/SU-E-02-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-02-04.jpg b/screenshots/SU-E-02-04.jpg deleted file mode 100644 index c5e9b683..00000000 Binary files a/screenshots/SU-E-02-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-02.jpg b/screenshots/SU-E-02.jpg deleted file mode 100644 index 5204a4b8..00000000 Binary files a/screenshots/SU-E-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-03-00.jpg b/screenshots/SU-E-03-00.jpg deleted file mode 100644 index 5881ca62..00000000 Binary files a/screenshots/SU-E-03-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-03-01.jpg b/screenshots/SU-E-03-01.jpg deleted file mode 100644 index aaaabeca..00000000 Binary files a/screenshots/SU-E-03-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-03-02.jpg b/screenshots/SU-E-03-02.jpg deleted file mode 100644 index 5aa84240..00000000 Binary files a/screenshots/SU-E-03-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-03-03.jpg b/screenshots/SU-E-03-03.jpg deleted file mode 100644 index 1874a4f3..00000000 Binary files a/screenshots/SU-E-03-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-03-04.jpg b/screenshots/SU-E-03-04.jpg deleted file mode 100644 index 58ddb116..00000000 Binary files a/screenshots/SU-E-03-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-03.jpg b/screenshots/SU-E-03.jpg deleted file mode 100644 index 89a8d10d..00000000 Binary files a/screenshots/SU-E-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-04-00.jpg b/screenshots/SU-E-04-00.jpg deleted file mode 100644 index 4942a85e..00000000 Binary files a/screenshots/SU-E-04-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-04-01.jpg b/screenshots/SU-E-04-01.jpg deleted file mode 100644 index 19003832..00000000 Binary files a/screenshots/SU-E-04-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-04-02.jpg b/screenshots/SU-E-04-02.jpg deleted file mode 100644 index 74a0eda0..00000000 Binary files a/screenshots/SU-E-04-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-04-03.jpg b/screenshots/SU-E-04-03.jpg deleted file mode 100644 index c2b2fc6f..00000000 Binary files a/screenshots/SU-E-04-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-04-04.jpg b/screenshots/SU-E-04-04.jpg deleted file mode 100644 index d09bbc77..00000000 Binary files a/screenshots/SU-E-04-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-04.jpg b/screenshots/SU-E-04.jpg deleted file mode 100644 index 458022d6..00000000 Binary files a/screenshots/SU-E-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-05-00.jpg b/screenshots/SU-E-05-00.jpg deleted file mode 100644 index f91ee61a..00000000 Binary files a/screenshots/SU-E-05-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-05-01.jpg b/screenshots/SU-E-05-01.jpg deleted file mode 100644 index ea368093..00000000 Binary files a/screenshots/SU-E-05-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-05-02.jpg b/screenshots/SU-E-05-02.jpg deleted file mode 100644 index 05f71507..00000000 Binary files a/screenshots/SU-E-05-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-05-03.jpg b/screenshots/SU-E-05-03.jpg deleted file mode 100644 index 20b3e67f..00000000 Binary files a/screenshots/SU-E-05-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-05-04.jpg b/screenshots/SU-E-05-04.jpg deleted file mode 100644 index 4e0deb57..00000000 Binary files a/screenshots/SU-E-05-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-05.jpg b/screenshots/SU-E-05.jpg deleted file mode 100644 index 99c7c490..00000000 Binary files a/screenshots/SU-E-05.jpg and /dev/null differ diff --git a/screenshots/SU-E-06-00.jpg b/screenshots/SU-E-06-00.jpg deleted file mode 100644 index 9985433f..00000000 Binary files a/screenshots/SU-E-06-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-06-01.jpg b/screenshots/SU-E-06-01.jpg deleted file mode 100644 index 7667e5de..00000000 Binary files a/screenshots/SU-E-06-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-06-02.jpg b/screenshots/SU-E-06-02.jpg deleted file mode 100644 index ff35a392..00000000 Binary files a/screenshots/SU-E-06-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-06-03.jpg b/screenshots/SU-E-06-03.jpg deleted file mode 100644 index b0aa4498..00000000 Binary files a/screenshots/SU-E-06-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-06-04.jpg b/screenshots/SU-E-06-04.jpg deleted file mode 100644 index a358989b..00000000 Binary files a/screenshots/SU-E-06-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-06.jpg b/screenshots/SU-E-06.jpg deleted file mode 100644 index 0b2b951d..00000000 Binary files a/screenshots/SU-E-06.jpg and /dev/null differ diff --git a/screenshots/SU-E-07-00.jpg b/screenshots/SU-E-07-00.jpg deleted file mode 100644 index 6d54fe12..00000000 Binary files a/screenshots/SU-E-07-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-07-01.jpg b/screenshots/SU-E-07-01.jpg deleted file mode 100644 index b715be67..00000000 Binary files a/screenshots/SU-E-07-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-07-02.jpg b/screenshots/SU-E-07-02.jpg deleted file mode 100644 index 264c151e..00000000 Binary files a/screenshots/SU-E-07-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-07-03.jpg b/screenshots/SU-E-07-03.jpg deleted file mode 100644 index 287ac057..00000000 Binary files a/screenshots/SU-E-07-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-07-04.jpg b/screenshots/SU-E-07-04.jpg deleted file mode 100644 index becf11e2..00000000 Binary files a/screenshots/SU-E-07-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-07.jpg b/screenshots/SU-E-07.jpg deleted file mode 100644 index 9f9a0a48..00000000 Binary files a/screenshots/SU-E-07.jpg and /dev/null differ diff --git a/screenshots/SU-E-08-00.jpg b/screenshots/SU-E-08-00.jpg deleted file mode 100644 index e352d971..00000000 Binary files a/screenshots/SU-E-08-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-08-01.jpg b/screenshots/SU-E-08-01.jpg deleted file mode 100644 index ad849fa3..00000000 Binary files a/screenshots/SU-E-08-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-08-02.jpg b/screenshots/SU-E-08-02.jpg deleted file mode 100644 index 01611e31..00000000 Binary files a/screenshots/SU-E-08-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-08-03.jpg b/screenshots/SU-E-08-03.jpg deleted file mode 100644 index acd0d3e4..00000000 Binary files a/screenshots/SU-E-08-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-08-04.jpg b/screenshots/SU-E-08-04.jpg deleted file mode 100644 index 5b94fe78..00000000 Binary files a/screenshots/SU-E-08-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-08.jpg b/screenshots/SU-E-08.jpg deleted file mode 100644 index 617094ad..00000000 Binary files a/screenshots/SU-E-08.jpg and /dev/null differ diff --git a/screenshots/SU-E-09-00.jpg b/screenshots/SU-E-09-00.jpg deleted file mode 100644 index 6c7e628d..00000000 Binary files a/screenshots/SU-E-09-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-09-01.jpg b/screenshots/SU-E-09-01.jpg deleted file mode 100644 index e59ca834..00000000 Binary files a/screenshots/SU-E-09-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-09-02.jpg b/screenshots/SU-E-09-02.jpg deleted file mode 100644 index 7722d4e3..00000000 Binary files a/screenshots/SU-E-09-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-09-03.jpg b/screenshots/SU-E-09-03.jpg deleted file mode 100644 index 82910351..00000000 Binary files a/screenshots/SU-E-09-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-09-04.jpg b/screenshots/SU-E-09-04.jpg deleted file mode 100644 index 192cf3bc..00000000 Binary files a/screenshots/SU-E-09-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-09.jpg b/screenshots/SU-E-09.jpg deleted file mode 100644 index 418a8daf..00000000 Binary files a/screenshots/SU-E-09.jpg and /dev/null differ diff --git a/screenshots/SU-E-10-00.jpg b/screenshots/SU-E-10-00.jpg deleted file mode 100644 index 11223c09..00000000 Binary files a/screenshots/SU-E-10-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-10-01.jpg b/screenshots/SU-E-10-01.jpg deleted file mode 100644 index f6d7ed1e..00000000 Binary files a/screenshots/SU-E-10-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-10-02.jpg b/screenshots/SU-E-10-02.jpg deleted file mode 100644 index 6a557518..00000000 Binary files a/screenshots/SU-E-10-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-10-03.jpg b/screenshots/SU-E-10-03.jpg deleted file mode 100644 index 2bfecaa7..00000000 Binary files a/screenshots/SU-E-10-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-10-04.jpg b/screenshots/SU-E-10-04.jpg deleted file mode 100644 index 44dc654c..00000000 Binary files a/screenshots/SU-E-10-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-10.jpg b/screenshots/SU-E-10.jpg deleted file mode 100644 index 5bd24aaa..00000000 Binary files a/screenshots/SU-E-10.jpg and /dev/null differ diff --git a/screenshots/SU-E-11-00.jpg b/screenshots/SU-E-11-00.jpg deleted file mode 100644 index d78514eb..00000000 Binary files a/screenshots/SU-E-11-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-11-01.jpg b/screenshots/SU-E-11-01.jpg deleted file mode 100644 index b1dc4084..00000000 Binary files a/screenshots/SU-E-11-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-11-02.jpg b/screenshots/SU-E-11-02.jpg deleted file mode 100644 index 018bf62a..00000000 Binary files a/screenshots/SU-E-11-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-11-03.jpg b/screenshots/SU-E-11-03.jpg deleted file mode 100644 index d7c29bf5..00000000 Binary files a/screenshots/SU-E-11-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-11-04.jpg b/screenshots/SU-E-11-04.jpg deleted file mode 100644 index 7987dc22..00000000 Binary files a/screenshots/SU-E-11-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-11.jpg b/screenshots/SU-E-11.jpg deleted file mode 100644 index 38d02148..00000000 Binary files a/screenshots/SU-E-11.jpg and /dev/null differ diff --git a/screenshots/SU-E-12-00.jpg b/screenshots/SU-E-12-00.jpg deleted file mode 100644 index ecc3b8ed..00000000 Binary files a/screenshots/SU-E-12-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-12-01.jpg b/screenshots/SU-E-12-01.jpg deleted file mode 100644 index 467d69e9..00000000 Binary files a/screenshots/SU-E-12-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-12-02.jpg b/screenshots/SU-E-12-02.jpg deleted file mode 100644 index 157951e7..00000000 Binary files a/screenshots/SU-E-12-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-12-03.jpg b/screenshots/SU-E-12-03.jpg deleted file mode 100644 index d9d5a42a..00000000 Binary files a/screenshots/SU-E-12-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-12-04.jpg b/screenshots/SU-E-12-04.jpg deleted file mode 100644 index 876a4b44..00000000 Binary files a/screenshots/SU-E-12-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-12.jpg b/screenshots/SU-E-12.jpg deleted file mode 100644 index 0fa62b9a..00000000 Binary files a/screenshots/SU-E-12.jpg and /dev/null differ diff --git a/screenshots/SU-E-13-00.jpg b/screenshots/SU-E-13-00.jpg deleted file mode 100644 index eaa5cf9d..00000000 Binary files a/screenshots/SU-E-13-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-13-01.jpg b/screenshots/SU-E-13-01.jpg deleted file mode 100644 index da8f2b9a..00000000 Binary files a/screenshots/SU-E-13-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-13-02.jpg b/screenshots/SU-E-13-02.jpg deleted file mode 100644 index 8bafa972..00000000 Binary files a/screenshots/SU-E-13-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-13-03.jpg b/screenshots/SU-E-13-03.jpg deleted file mode 100644 index a77a11ea..00000000 Binary files a/screenshots/SU-E-13-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-13-04.jpg b/screenshots/SU-E-13-04.jpg deleted file mode 100644 index 538a06b5..00000000 Binary files a/screenshots/SU-E-13-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-13.jpg b/screenshots/SU-E-13.jpg deleted file mode 100644 index fa5e8497..00000000 Binary files a/screenshots/SU-E-13.jpg and /dev/null differ diff --git a/screenshots/SU-E-14-00.jpg b/screenshots/SU-E-14-00.jpg deleted file mode 100644 index d259f1bb..00000000 Binary files a/screenshots/SU-E-14-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-14-01.jpg b/screenshots/SU-E-14-01.jpg deleted file mode 100644 index 8098e202..00000000 Binary files a/screenshots/SU-E-14-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-14-02.jpg b/screenshots/SU-E-14-02.jpg deleted file mode 100644 index d4eb6b00..00000000 Binary files a/screenshots/SU-E-14-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-14-03.jpg b/screenshots/SU-E-14-03.jpg deleted file mode 100644 index 0c091055..00000000 Binary files a/screenshots/SU-E-14-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-14-04.jpg b/screenshots/SU-E-14-04.jpg deleted file mode 100644 index b165bb97..00000000 Binary files a/screenshots/SU-E-14-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-14.jpg b/screenshots/SU-E-14.jpg deleted file mode 100644 index bb2c4ddc..00000000 Binary files a/screenshots/SU-E-14.jpg and /dev/null differ diff --git a/screenshots/SU-E-15-00.jpg b/screenshots/SU-E-15-00.jpg deleted file mode 100644 index 4be440ab..00000000 Binary files a/screenshots/SU-E-15-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-15-01.jpg b/screenshots/SU-E-15-01.jpg deleted file mode 100644 index 0a86c352..00000000 Binary files a/screenshots/SU-E-15-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-15-02.jpg b/screenshots/SU-E-15-02.jpg deleted file mode 100644 index af35ff33..00000000 Binary files a/screenshots/SU-E-15-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-15-03.jpg b/screenshots/SU-E-15-03.jpg deleted file mode 100644 index 7f7a6360..00000000 Binary files a/screenshots/SU-E-15-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-15-04.jpg b/screenshots/SU-E-15-04.jpg deleted file mode 100644 index a70dfd4f..00000000 Binary files a/screenshots/SU-E-15-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-15.jpg b/screenshots/SU-E-15.jpg deleted file mode 100644 index 3caa32f0..00000000 Binary files a/screenshots/SU-E-15.jpg and /dev/null differ diff --git a/screenshots/SU-E-16-00.jpg b/screenshots/SU-E-16-00.jpg deleted file mode 100644 index b9ffa086..00000000 Binary files a/screenshots/SU-E-16-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-16-01.jpg b/screenshots/SU-E-16-01.jpg deleted file mode 100644 index 8fc93f49..00000000 Binary files a/screenshots/SU-E-16-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-16-02.jpg b/screenshots/SU-E-16-02.jpg deleted file mode 100644 index 0b74f1a1..00000000 Binary files a/screenshots/SU-E-16-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-16-03.jpg b/screenshots/SU-E-16-03.jpg deleted file mode 100644 index 6832dcad..00000000 Binary files a/screenshots/SU-E-16-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-16-04.jpg b/screenshots/SU-E-16-04.jpg deleted file mode 100644 index b373efda..00000000 Binary files a/screenshots/SU-E-16-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-16.jpg b/screenshots/SU-E-16.jpg deleted file mode 100644 index 637c6d04..00000000 Binary files a/screenshots/SU-E-16.jpg and /dev/null differ diff --git a/screenshots/SU-E-17-00.jpg b/screenshots/SU-E-17-00.jpg deleted file mode 100644 index 9da0037a..00000000 Binary files a/screenshots/SU-E-17-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-17-01.jpg b/screenshots/SU-E-17-01.jpg deleted file mode 100644 index 7fc5c86e..00000000 Binary files a/screenshots/SU-E-17-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-17-02.jpg b/screenshots/SU-E-17-02.jpg deleted file mode 100644 index cbfcc68e..00000000 Binary files a/screenshots/SU-E-17-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-17-03.jpg b/screenshots/SU-E-17-03.jpg deleted file mode 100644 index c7753503..00000000 Binary files a/screenshots/SU-E-17-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-17-04.jpg b/screenshots/SU-E-17-04.jpg deleted file mode 100644 index d80fc1cc..00000000 Binary files a/screenshots/SU-E-17-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-17.jpg b/screenshots/SU-E-17.jpg deleted file mode 100644 index f130a1b2..00000000 Binary files a/screenshots/SU-E-17.jpg and /dev/null differ diff --git a/screenshots/SU-E-18-00.jpg b/screenshots/SU-E-18-00.jpg deleted file mode 100644 index dae8a3c4..00000000 Binary files a/screenshots/SU-E-18-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-18-01.jpg b/screenshots/SU-E-18-01.jpg deleted file mode 100644 index 44e035f2..00000000 Binary files a/screenshots/SU-E-18-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-18-02.jpg b/screenshots/SU-E-18-02.jpg deleted file mode 100644 index bc32c9ea..00000000 Binary files a/screenshots/SU-E-18-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-18-03.jpg b/screenshots/SU-E-18-03.jpg deleted file mode 100644 index c8f0c1b1..00000000 Binary files a/screenshots/SU-E-18-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-18-04.jpg b/screenshots/SU-E-18-04.jpg deleted file mode 100644 index 9827429e..00000000 Binary files a/screenshots/SU-E-18-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-18.jpg b/screenshots/SU-E-18.jpg deleted file mode 100644 index e2ee59b2..00000000 Binary files a/screenshots/SU-E-18.jpg and /dev/null differ diff --git a/screenshots/SU-E-19-00.jpg b/screenshots/SU-E-19-00.jpg deleted file mode 100644 index 84032f79..00000000 Binary files a/screenshots/SU-E-19-00.jpg and /dev/null differ diff --git a/screenshots/SU-E-19-01.jpg b/screenshots/SU-E-19-01.jpg deleted file mode 100644 index 34a3f96e..00000000 Binary files a/screenshots/SU-E-19-01.jpg and /dev/null differ diff --git a/screenshots/SU-E-19-02.jpg b/screenshots/SU-E-19-02.jpg deleted file mode 100644 index 8ac93326..00000000 Binary files a/screenshots/SU-E-19-02.jpg and /dev/null differ diff --git a/screenshots/SU-E-19-03.jpg b/screenshots/SU-E-19-03.jpg deleted file mode 100644 index b4b8170a..00000000 Binary files a/screenshots/SU-E-19-03.jpg and /dev/null differ diff --git a/screenshots/SU-E-19-04.jpg b/screenshots/SU-E-19-04.jpg deleted file mode 100644 index 11a37f94..00000000 Binary files a/screenshots/SU-E-19-04.jpg and /dev/null differ diff --git a/screenshots/SU-E-19.jpg b/screenshots/SU-E-19.jpg deleted file mode 100644 index 820982ba..00000000 Binary files a/screenshots/SU-E-19.jpg and /dev/null differ diff --git a/screenshots/SU-X-00-00.jpg b/screenshots/SU-X-00-00.jpg deleted file mode 100644 index 1b585ab6..00000000 Binary files a/screenshots/SU-X-00-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-00-01.jpg b/screenshots/SU-X-00-01.jpg deleted file mode 100644 index c6a00009..00000000 Binary files a/screenshots/SU-X-00-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-00-02.jpg b/screenshots/SU-X-00-02.jpg deleted file mode 100644 index f1c78794..00000000 Binary files a/screenshots/SU-X-00-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-00-03.jpg b/screenshots/SU-X-00-03.jpg deleted file mode 100644 index e031dd17..00000000 Binary files a/screenshots/SU-X-00-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-00-04.jpg b/screenshots/SU-X-00-04.jpg deleted file mode 100644 index 9a169a1c..00000000 Binary files a/screenshots/SU-X-00-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-00.jpg b/screenshots/SU-X-00.jpg deleted file mode 100644 index 972918a5..00000000 Binary files a/screenshots/SU-X-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-01-00.jpg b/screenshots/SU-X-01-00.jpg deleted file mode 100644 index 1f3c42ff..00000000 Binary files a/screenshots/SU-X-01-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-01-01.jpg b/screenshots/SU-X-01-01.jpg deleted file mode 100644 index 4d4d586b..00000000 Binary files a/screenshots/SU-X-01-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-01-02.jpg b/screenshots/SU-X-01-02.jpg deleted file mode 100644 index 6d232303..00000000 Binary files a/screenshots/SU-X-01-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-01-03.jpg b/screenshots/SU-X-01-03.jpg deleted file mode 100644 index d7a43f53..00000000 Binary files a/screenshots/SU-X-01-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-01-04.jpg b/screenshots/SU-X-01-04.jpg deleted file mode 100644 index 89c79649..00000000 Binary files a/screenshots/SU-X-01-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-01.jpg b/screenshots/SU-X-01.jpg deleted file mode 100644 index e196252d..00000000 Binary files a/screenshots/SU-X-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-02-00.jpg b/screenshots/SU-X-02-00.jpg deleted file mode 100644 index 781c8797..00000000 Binary files a/screenshots/SU-X-02-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-02-01.jpg b/screenshots/SU-X-02-01.jpg deleted file mode 100644 index 21799e4c..00000000 Binary files a/screenshots/SU-X-02-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-02-02.jpg b/screenshots/SU-X-02-02.jpg deleted file mode 100644 index 9c474ded..00000000 Binary files a/screenshots/SU-X-02-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-02-03.jpg b/screenshots/SU-X-02-03.jpg deleted file mode 100644 index 1a0307f1..00000000 Binary files a/screenshots/SU-X-02-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-02-04.jpg b/screenshots/SU-X-02-04.jpg deleted file mode 100644 index e31533dc..00000000 Binary files a/screenshots/SU-X-02-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-02.jpg b/screenshots/SU-X-02.jpg deleted file mode 100644 index 2e3a10c0..00000000 Binary files a/screenshots/SU-X-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-03-00.jpg b/screenshots/SU-X-03-00.jpg deleted file mode 100644 index e4206cb4..00000000 Binary files a/screenshots/SU-X-03-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-03-01.jpg b/screenshots/SU-X-03-01.jpg deleted file mode 100644 index f75f6678..00000000 Binary files a/screenshots/SU-X-03-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-03-02.jpg b/screenshots/SU-X-03-02.jpg deleted file mode 100644 index 8f6e5f07..00000000 Binary files a/screenshots/SU-X-03-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-03-03.jpg b/screenshots/SU-X-03-03.jpg deleted file mode 100644 index 139e3e95..00000000 Binary files a/screenshots/SU-X-03-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-03-04.jpg b/screenshots/SU-X-03-04.jpg deleted file mode 100644 index 7f6df3ca..00000000 Binary files a/screenshots/SU-X-03-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-03.jpg b/screenshots/SU-X-03.jpg deleted file mode 100644 index 7d1690f6..00000000 Binary files a/screenshots/SU-X-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-04-00.jpg b/screenshots/SU-X-04-00.jpg deleted file mode 100644 index fdf7623c..00000000 Binary files a/screenshots/SU-X-04-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-04-01.jpg b/screenshots/SU-X-04-01.jpg deleted file mode 100644 index 2209abb6..00000000 Binary files a/screenshots/SU-X-04-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-04-02.jpg b/screenshots/SU-X-04-02.jpg deleted file mode 100644 index 9ce8c9b7..00000000 Binary files a/screenshots/SU-X-04-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-04-03.jpg b/screenshots/SU-X-04-03.jpg deleted file mode 100644 index 85082a6f..00000000 Binary files a/screenshots/SU-X-04-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-04-04.jpg b/screenshots/SU-X-04-04.jpg deleted file mode 100644 index 81e99dd0..00000000 Binary files a/screenshots/SU-X-04-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-04.jpg b/screenshots/SU-X-04.jpg deleted file mode 100644 index 2ce936f2..00000000 Binary files a/screenshots/SU-X-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-05-00.jpg b/screenshots/SU-X-05-00.jpg deleted file mode 100644 index e40e26b2..00000000 Binary files a/screenshots/SU-X-05-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-05-01.jpg b/screenshots/SU-X-05-01.jpg deleted file mode 100644 index 102bf077..00000000 Binary files a/screenshots/SU-X-05-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-05-02.jpg b/screenshots/SU-X-05-02.jpg deleted file mode 100644 index 53e5a130..00000000 Binary files a/screenshots/SU-X-05-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-05-03.jpg b/screenshots/SU-X-05-03.jpg deleted file mode 100644 index 49b28b56..00000000 Binary files a/screenshots/SU-X-05-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-05-04.jpg b/screenshots/SU-X-05-04.jpg deleted file mode 100644 index 8735f9a1..00000000 Binary files a/screenshots/SU-X-05-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-05.jpg b/screenshots/SU-X-05.jpg deleted file mode 100644 index c98a7b60..00000000 Binary files a/screenshots/SU-X-05.jpg and /dev/null differ diff --git a/screenshots/SU-X-06-00.jpg b/screenshots/SU-X-06-00.jpg deleted file mode 100644 index ea72aa32..00000000 Binary files a/screenshots/SU-X-06-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-06-01.jpg b/screenshots/SU-X-06-01.jpg deleted file mode 100644 index 0d0bcd84..00000000 Binary files a/screenshots/SU-X-06-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-06-02.jpg b/screenshots/SU-X-06-02.jpg deleted file mode 100644 index 6ccb9f48..00000000 Binary files a/screenshots/SU-X-06-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-06-03.jpg b/screenshots/SU-X-06-03.jpg deleted file mode 100644 index 34316e25..00000000 Binary files a/screenshots/SU-X-06-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-06-04.jpg b/screenshots/SU-X-06-04.jpg deleted file mode 100644 index c8d3b042..00000000 Binary files a/screenshots/SU-X-06-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-06.jpg b/screenshots/SU-X-06.jpg deleted file mode 100644 index 7e57661e..00000000 Binary files a/screenshots/SU-X-06.jpg and /dev/null differ diff --git a/screenshots/SU-X-07-00.jpg b/screenshots/SU-X-07-00.jpg deleted file mode 100644 index eccc97ff..00000000 Binary files a/screenshots/SU-X-07-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-07-01.jpg b/screenshots/SU-X-07-01.jpg deleted file mode 100644 index 03c6e9c6..00000000 Binary files a/screenshots/SU-X-07-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-07-02.jpg b/screenshots/SU-X-07-02.jpg deleted file mode 100644 index 06e43e8d..00000000 Binary files a/screenshots/SU-X-07-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-07-03.jpg b/screenshots/SU-X-07-03.jpg deleted file mode 100644 index 1b481020..00000000 Binary files a/screenshots/SU-X-07-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-07-04.jpg b/screenshots/SU-X-07-04.jpg deleted file mode 100644 index 3e123892..00000000 Binary files a/screenshots/SU-X-07-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-07.jpg b/screenshots/SU-X-07.jpg deleted file mode 100644 index 7d4fd0cb..00000000 Binary files a/screenshots/SU-X-07.jpg and /dev/null differ diff --git a/screenshots/SU-X-08-00.jpg b/screenshots/SU-X-08-00.jpg deleted file mode 100644 index 50cd5ae0..00000000 Binary files a/screenshots/SU-X-08-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-08-01.jpg b/screenshots/SU-X-08-01.jpg deleted file mode 100644 index df4da23b..00000000 Binary files a/screenshots/SU-X-08-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-08-02.jpg b/screenshots/SU-X-08-02.jpg deleted file mode 100644 index e6e85f11..00000000 Binary files a/screenshots/SU-X-08-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-08-03.jpg b/screenshots/SU-X-08-03.jpg deleted file mode 100644 index 948e8e8a..00000000 Binary files a/screenshots/SU-X-08-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-08-04.jpg b/screenshots/SU-X-08-04.jpg deleted file mode 100644 index fe15ebbd..00000000 Binary files a/screenshots/SU-X-08-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-08.jpg b/screenshots/SU-X-08.jpg deleted file mode 100644 index 349efbaa..00000000 Binary files a/screenshots/SU-X-08.jpg and /dev/null differ diff --git a/screenshots/SU-X-09-00.jpg b/screenshots/SU-X-09-00.jpg deleted file mode 100644 index c1c179ce..00000000 Binary files a/screenshots/SU-X-09-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-09-01.jpg b/screenshots/SU-X-09-01.jpg deleted file mode 100644 index 67f4f787..00000000 Binary files a/screenshots/SU-X-09-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-09-02.jpg b/screenshots/SU-X-09-02.jpg deleted file mode 100644 index cfc3803b..00000000 Binary files a/screenshots/SU-X-09-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-09-03.jpg b/screenshots/SU-X-09-03.jpg deleted file mode 100644 index 8e59c690..00000000 Binary files a/screenshots/SU-X-09-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-09-04.jpg b/screenshots/SU-X-09-04.jpg deleted file mode 100644 index 78865584..00000000 Binary files a/screenshots/SU-X-09-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-09.jpg b/screenshots/SU-X-09.jpg deleted file mode 100644 index c3697dd5..00000000 Binary files a/screenshots/SU-X-09.jpg and /dev/null differ diff --git a/screenshots/SU-X-10-00.jpg b/screenshots/SU-X-10-00.jpg deleted file mode 100644 index 1e7f59c4..00000000 Binary files a/screenshots/SU-X-10-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-10-01.jpg b/screenshots/SU-X-10-01.jpg deleted file mode 100644 index 1ca2f582..00000000 Binary files a/screenshots/SU-X-10-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-10-02.jpg b/screenshots/SU-X-10-02.jpg deleted file mode 100644 index 60f2968d..00000000 Binary files a/screenshots/SU-X-10-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-10-03.jpg b/screenshots/SU-X-10-03.jpg deleted file mode 100644 index 5cfdfd34..00000000 Binary files a/screenshots/SU-X-10-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-10-04.jpg b/screenshots/SU-X-10-04.jpg deleted file mode 100644 index c2168ae3..00000000 Binary files a/screenshots/SU-X-10-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-10.jpg b/screenshots/SU-X-10.jpg deleted file mode 100644 index 349755dc..00000000 Binary files a/screenshots/SU-X-10.jpg and /dev/null differ diff --git a/screenshots/SU-X-11-00.jpg b/screenshots/SU-X-11-00.jpg deleted file mode 100644 index 4300c83e..00000000 Binary files a/screenshots/SU-X-11-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-11-01.jpg b/screenshots/SU-X-11-01.jpg deleted file mode 100644 index 459ffd9d..00000000 Binary files a/screenshots/SU-X-11-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-11-02.jpg b/screenshots/SU-X-11-02.jpg deleted file mode 100644 index 274a281f..00000000 Binary files a/screenshots/SU-X-11-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-11-03.jpg b/screenshots/SU-X-11-03.jpg deleted file mode 100644 index 27b33920..00000000 Binary files a/screenshots/SU-X-11-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-11-04.jpg b/screenshots/SU-X-11-04.jpg deleted file mode 100644 index d1d5a9ff..00000000 Binary files a/screenshots/SU-X-11-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-11.jpg b/screenshots/SU-X-11.jpg deleted file mode 100644 index 4578dc56..00000000 Binary files a/screenshots/SU-X-11.jpg and /dev/null differ diff --git a/screenshots/SU-X-12-00.jpg b/screenshots/SU-X-12-00.jpg deleted file mode 100644 index d4abe8ca..00000000 Binary files a/screenshots/SU-X-12-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-12-01.jpg b/screenshots/SU-X-12-01.jpg deleted file mode 100644 index 92c251de..00000000 Binary files a/screenshots/SU-X-12-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-12-02.jpg b/screenshots/SU-X-12-02.jpg deleted file mode 100644 index 0a82ab4e..00000000 Binary files a/screenshots/SU-X-12-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-12-03.jpg b/screenshots/SU-X-12-03.jpg deleted file mode 100644 index dbc26c18..00000000 Binary files a/screenshots/SU-X-12-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-12-04.jpg b/screenshots/SU-X-12-04.jpg deleted file mode 100644 index 20d702e3..00000000 Binary files a/screenshots/SU-X-12-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-12.jpg b/screenshots/SU-X-12.jpg deleted file mode 100644 index 00b7a8a9..00000000 Binary files a/screenshots/SU-X-12.jpg and /dev/null differ diff --git a/screenshots/SU-X-13-00.jpg b/screenshots/SU-X-13-00.jpg deleted file mode 100644 index 3074a109..00000000 Binary files a/screenshots/SU-X-13-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-13-01.jpg b/screenshots/SU-X-13-01.jpg deleted file mode 100644 index b2d635fe..00000000 Binary files a/screenshots/SU-X-13-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-13-02.jpg b/screenshots/SU-X-13-02.jpg deleted file mode 100644 index 2411ea66..00000000 Binary files a/screenshots/SU-X-13-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-13-03.jpg b/screenshots/SU-X-13-03.jpg deleted file mode 100644 index 637da5d2..00000000 Binary files a/screenshots/SU-X-13-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-13-04.jpg b/screenshots/SU-X-13-04.jpg deleted file mode 100644 index 7106cec1..00000000 Binary files a/screenshots/SU-X-13-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-13.jpg b/screenshots/SU-X-13.jpg deleted file mode 100644 index ade0888b..00000000 Binary files a/screenshots/SU-X-13.jpg and /dev/null differ diff --git a/screenshots/SU-X-14-00.jpg b/screenshots/SU-X-14-00.jpg deleted file mode 100644 index 84739d3e..00000000 Binary files a/screenshots/SU-X-14-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-14-01.jpg b/screenshots/SU-X-14-01.jpg deleted file mode 100644 index 1e35c6db..00000000 Binary files a/screenshots/SU-X-14-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-14-02.jpg b/screenshots/SU-X-14-02.jpg deleted file mode 100644 index 1e2ea73e..00000000 Binary files a/screenshots/SU-X-14-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-14-03.jpg b/screenshots/SU-X-14-03.jpg deleted file mode 100644 index b0c2c653..00000000 Binary files a/screenshots/SU-X-14-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-14-04.jpg b/screenshots/SU-X-14-04.jpg deleted file mode 100644 index 37697a87..00000000 Binary files a/screenshots/SU-X-14-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-14.jpg b/screenshots/SU-X-14.jpg deleted file mode 100644 index b603d3f2..00000000 Binary files a/screenshots/SU-X-14.jpg and /dev/null differ diff --git a/screenshots/SU-X-15-00.jpg b/screenshots/SU-X-15-00.jpg deleted file mode 100644 index a657acc3..00000000 Binary files a/screenshots/SU-X-15-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-15-01.jpg b/screenshots/SU-X-15-01.jpg deleted file mode 100644 index d6e8622a..00000000 Binary files a/screenshots/SU-X-15-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-15-02.jpg b/screenshots/SU-X-15-02.jpg deleted file mode 100644 index e806ceda..00000000 Binary files a/screenshots/SU-X-15-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-15-03.jpg b/screenshots/SU-X-15-03.jpg deleted file mode 100644 index 6692ff31..00000000 Binary files a/screenshots/SU-X-15-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-15-04.jpg b/screenshots/SU-X-15-04.jpg deleted file mode 100644 index c4d25301..00000000 Binary files a/screenshots/SU-X-15-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-15.jpg b/screenshots/SU-X-15.jpg deleted file mode 100644 index 4b97af03..00000000 Binary files a/screenshots/SU-X-15.jpg and /dev/null differ diff --git a/screenshots/SU-X-16-00.jpg b/screenshots/SU-X-16-00.jpg deleted file mode 100644 index 9b4b3702..00000000 Binary files a/screenshots/SU-X-16-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-16-01.jpg b/screenshots/SU-X-16-01.jpg deleted file mode 100644 index 1b355bbf..00000000 Binary files a/screenshots/SU-X-16-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-16-02.jpg b/screenshots/SU-X-16-02.jpg deleted file mode 100644 index 30166be1..00000000 Binary files a/screenshots/SU-X-16-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-16-03.jpg b/screenshots/SU-X-16-03.jpg deleted file mode 100644 index 425bca24..00000000 Binary files a/screenshots/SU-X-16-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-16-04.jpg b/screenshots/SU-X-16-04.jpg deleted file mode 100644 index 1e85a7d4..00000000 Binary files a/screenshots/SU-X-16-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-16.jpg b/screenshots/SU-X-16.jpg deleted file mode 100644 index 8d04b82a..00000000 Binary files a/screenshots/SU-X-16.jpg and /dev/null differ diff --git a/screenshots/SU-X-17-00.jpg b/screenshots/SU-X-17-00.jpg deleted file mode 100644 index d8678886..00000000 Binary files a/screenshots/SU-X-17-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-17-01.jpg b/screenshots/SU-X-17-01.jpg deleted file mode 100644 index 83befc47..00000000 Binary files a/screenshots/SU-X-17-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-17-02.jpg b/screenshots/SU-X-17-02.jpg deleted file mode 100644 index 7b2875c5..00000000 Binary files a/screenshots/SU-X-17-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-17-03.jpg b/screenshots/SU-X-17-03.jpg deleted file mode 100644 index cdb97ced..00000000 Binary files a/screenshots/SU-X-17-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-17-04.jpg b/screenshots/SU-X-17-04.jpg deleted file mode 100644 index ea46c167..00000000 Binary files a/screenshots/SU-X-17-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-17.jpg b/screenshots/SU-X-17.jpg deleted file mode 100644 index b2353bd0..00000000 Binary files a/screenshots/SU-X-17.jpg and /dev/null differ diff --git a/screenshots/SU-X-18-00.jpg b/screenshots/SU-X-18-00.jpg deleted file mode 100644 index 2226bb22..00000000 Binary files a/screenshots/SU-X-18-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-18-01.jpg b/screenshots/SU-X-18-01.jpg deleted file mode 100644 index 09a07f98..00000000 Binary files a/screenshots/SU-X-18-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-18-02.jpg b/screenshots/SU-X-18-02.jpg deleted file mode 100644 index 1acba83d..00000000 Binary files a/screenshots/SU-X-18-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-18-03.jpg b/screenshots/SU-X-18-03.jpg deleted file mode 100644 index 1e0d29ce..00000000 Binary files a/screenshots/SU-X-18-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-18-04.jpg b/screenshots/SU-X-18-04.jpg deleted file mode 100644 index 4ee0b490..00000000 Binary files a/screenshots/SU-X-18-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-18.jpg b/screenshots/SU-X-18.jpg deleted file mode 100644 index c321b97d..00000000 Binary files a/screenshots/SU-X-18.jpg and /dev/null differ diff --git a/screenshots/SU-X-19-00.jpg b/screenshots/SU-X-19-00.jpg deleted file mode 100644 index 59d68dc8..00000000 Binary files a/screenshots/SU-X-19-00.jpg and /dev/null differ diff --git a/screenshots/SU-X-19-01.jpg b/screenshots/SU-X-19-01.jpg deleted file mode 100644 index 5b57df62..00000000 Binary files a/screenshots/SU-X-19-01.jpg and /dev/null differ diff --git a/screenshots/SU-X-19-02.jpg b/screenshots/SU-X-19-02.jpg deleted file mode 100644 index bdff4749..00000000 Binary files a/screenshots/SU-X-19-02.jpg and /dev/null differ diff --git a/screenshots/SU-X-19-03.jpg b/screenshots/SU-X-19-03.jpg deleted file mode 100644 index 998b58fc..00000000 Binary files a/screenshots/SU-X-19-03.jpg and /dev/null differ diff --git a/screenshots/SU-X-19-04.jpg b/screenshots/SU-X-19-04.jpg deleted file mode 100644 index eab162ee..00000000 Binary files a/screenshots/SU-X-19-04.jpg and /dev/null differ diff --git a/screenshots/SU-X-19.jpg b/screenshots/SU-X-19.jpg deleted file mode 100644 index 4d088b51..00000000 Binary files a/screenshots/SU-X-19.jpg and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-29-58-58.png b/screenshots/x/N++ 2017-06-15 14-29-58-58.png deleted file mode 100644 index b92c8de5..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-29-58-58.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-02-85.png b/screenshots/x/N++ 2017-06-15 14-30-02-85.png deleted file mode 100644 index 2ca84ac4..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-02-85.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-05-84.png b/screenshots/x/N++ 2017-06-15 14-30-05-84.png deleted file mode 100644 index 1e9204c0..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-05-84.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-10-05.png b/screenshots/x/N++ 2017-06-15 14-30-10-05.png deleted file mode 100644 index 26cddab1..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-10-05.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-13-08.png b/screenshots/x/N++ 2017-06-15 14-30-13-08.png deleted file mode 100644 index 30579c27..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-13-08.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-17-76.png b/screenshots/x/N++ 2017-06-15 14-30-17-76.png deleted file mode 100644 index e351b887..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-17-76.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-20-90.png b/screenshots/x/N++ 2017-06-15 14-30-20-90.png deleted file mode 100644 index 966420cc..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-20-90.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-23-44.png b/screenshots/x/N++ 2017-06-15 14-30-23-44.png deleted file mode 100644 index 5eb2abf6..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-23-44.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-26-08.png b/screenshots/x/N++ 2017-06-15 14-30-26-08.png deleted file mode 100644 index 41efaa66..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-26-08.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-28-68.png b/screenshots/x/N++ 2017-06-15 14-30-28-68.png deleted file mode 100644 index c8c2493a..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-28-68.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-32-32.png b/screenshots/x/N++ 2017-06-15 14-30-32-32.png deleted file mode 100644 index 348392f8..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-32-32.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-34-74.png b/screenshots/x/N++ 2017-06-15 14-30-34-74.png deleted file mode 100644 index 41e01119..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-34-74.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-36-92.png b/screenshots/x/N++ 2017-06-15 14-30-36-92.png deleted file mode 100644 index ba4dcb1a..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-36-92.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-39-12.png b/screenshots/x/N++ 2017-06-15 14-30-39-12.png deleted file mode 100644 index 80ea4c88..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-39-12.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-30-41-24.png b/screenshots/x/N++ 2017-06-15 14-30-41-24.png deleted file mode 100644 index 201acfcc..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-30-41-24.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-31-43-25.png b/screenshots/x/N++ 2017-06-15 14-31-43-25.png deleted file mode 100644 index 7799b934..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-31-43-25.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-31-46-46.png b/screenshots/x/N++ 2017-06-15 14-31-46-46.png deleted file mode 100644 index d6b84d9b..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-31-46-46.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-31-49-60.png b/screenshots/x/N++ 2017-06-15 14-31-49-60.png deleted file mode 100644 index 49b306c5..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-31-49-60.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-31-52-53.png b/screenshots/x/N++ 2017-06-15 14-31-52-53.png deleted file mode 100644 index ab257cad..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-31-52-53.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-31-54-88.png b/screenshots/x/N++ 2017-06-15 14-31-54-88.png deleted file mode 100644 index 24ab24e5..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-31-54-88.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-31-58-31.png b/screenshots/x/N++ 2017-06-15 14-31-58-31.png deleted file mode 100644 index 4661cc3c..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-31-58-31.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-00-69.png b/screenshots/x/N++ 2017-06-15 14-32-00-69.png deleted file mode 100644 index 980a83cd..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-00-69.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-02-80.png b/screenshots/x/N++ 2017-06-15 14-32-02-80.png deleted file mode 100644 index 09257d44..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-02-80.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-04-87.png b/screenshots/x/N++ 2017-06-15 14-32-04-87.png deleted file mode 100644 index c8c6c3f1..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-04-87.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-07-03.png b/screenshots/x/N++ 2017-06-15 14-32-07-03.png deleted file mode 100644 index fca1e244..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-07-03.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-10-53.png b/screenshots/x/N++ 2017-06-15 14-32-10-53.png deleted file mode 100644 index b98bd96f..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-10-53.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-12-70.png b/screenshots/x/N++ 2017-06-15 14-32-12-70.png deleted file mode 100644 index c3bf2c64..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-12-70.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-15-49.png b/screenshots/x/N++ 2017-06-15 14-32-15-49.png deleted file mode 100644 index c54934a7..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-15-49.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-18-15.png b/screenshots/x/N++ 2017-06-15 14-32-18-15.png deleted file mode 100644 index a938f334..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-18-15.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-20-57.png b/screenshots/x/N++ 2017-06-15 14-32-20-57.png deleted file mode 100644 index 99838953..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-20-57.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-42-86.png b/screenshots/x/N++ 2017-06-15 14-32-42-86.png deleted file mode 100644 index 32523a26..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-42-86.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-45-45.png b/screenshots/x/N++ 2017-06-15 14-32-45-45.png deleted file mode 100644 index d7e5cb7b..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-45-45.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-48-14.png b/screenshots/x/N++ 2017-06-15 14-32-48-14.png deleted file mode 100644 index a76b3cdc..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-48-14.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-50-44.png b/screenshots/x/N++ 2017-06-15 14-32-50-44.png deleted file mode 100644 index 5c24b70c..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-50-44.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-52-39.png b/screenshots/x/N++ 2017-06-15 14-32-52-39.png deleted file mode 100644 index 3bfd69a8..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-52-39.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-55-31.png b/screenshots/x/N++ 2017-06-15 14-32-55-31.png deleted file mode 100644 index 2f916d55..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-55-31.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-57-37.png b/screenshots/x/N++ 2017-06-15 14-32-57-37.png deleted file mode 100644 index 5556f4ab..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-57-37.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-32-59-37.png b/screenshots/x/N++ 2017-06-15 14-32-59-37.png deleted file mode 100644 index 825c2384..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-32-59-37.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-01-38.png b/screenshots/x/N++ 2017-06-15 14-33-01-38.png deleted file mode 100644 index 561cbc4b..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-01-38.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-03-53.png b/screenshots/x/N++ 2017-06-15 14-33-03-53.png deleted file mode 100644 index 8293d0b9..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-03-53.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-06-40.png b/screenshots/x/N++ 2017-06-15 14-33-06-40.png deleted file mode 100644 index e3006495..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-06-40.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-08-49.png b/screenshots/x/N++ 2017-06-15 14-33-08-49.png deleted file mode 100644 index 5adbb9fe..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-08-49.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-10-82.png b/screenshots/x/N++ 2017-06-15 14-33-10-82.png deleted file mode 100644 index 965fe6ab..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-10-82.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-13-04.png b/screenshots/x/N++ 2017-06-15 14-33-13-04.png deleted file mode 100644 index b35746da..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-13-04.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-15-08.png b/screenshots/x/N++ 2017-06-15 14-33-15-08.png deleted file mode 100644 index dbb8a7f8..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-15-08.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-17-74.png b/screenshots/x/N++ 2017-06-15 14-33-17-74.png deleted file mode 100644 index ff6c66e5..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-17-74.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-19-86.png b/screenshots/x/N++ 2017-06-15 14-33-19-86.png deleted file mode 100644 index 316b71c4..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-19-86.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-21-90.png b/screenshots/x/N++ 2017-06-15 14-33-21-90.png deleted file mode 100644 index 9f7c4f3e..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-21-90.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-23-92.png b/screenshots/x/N++ 2017-06-15 14-33-23-92.png deleted file mode 100644 index 97783893..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-23-92.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-25-93.png b/screenshots/x/N++ 2017-06-15 14-33-25-93.png deleted file mode 100644 index 54aa4b4d..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-25-93.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-28-75.png b/screenshots/x/N++ 2017-06-15 14-33-28-75.png deleted file mode 100644 index 98d3884f..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-28-75.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-30-90.png b/screenshots/x/N++ 2017-06-15 14-33-30-90.png deleted file mode 100644 index 6d6bd6c5..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-30-90.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-33-13.png b/screenshots/x/N++ 2017-06-15 14-33-33-13.png deleted file mode 100644 index 5f0e4744..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-33-13.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-35-33.png b/screenshots/x/N++ 2017-06-15 14-33-35-33.png deleted file mode 100644 index 82075f2d..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-35-33.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-37-39.png b/screenshots/x/N++ 2017-06-15 14-33-37-39.png deleted file mode 100644 index e192b16e..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-37-39.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-40-88.png b/screenshots/x/N++ 2017-06-15 14-33-40-88.png deleted file mode 100644 index 9d6d4395..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-40-88.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-42-87.png b/screenshots/x/N++ 2017-06-15 14-33-42-87.png deleted file mode 100644 index 670ed5e7..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-42-87.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-44-87.png b/screenshots/x/N++ 2017-06-15 14-33-44-87.png deleted file mode 100644 index 96bff339..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-44-87.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-49-15.png b/screenshots/x/N++ 2017-06-15 14-33-49-15.png deleted file mode 100644 index e6ba88ce..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-49-15.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-33-51-62.png b/screenshots/x/N++ 2017-06-15 14-33-51-62.png deleted file mode 100644 index 16681067..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-33-51-62.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-16-69.png b/screenshots/x/N++ 2017-06-15 14-34-16-69.png deleted file mode 100644 index 5eb64922..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-16-69.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-20-45.png b/screenshots/x/N++ 2017-06-15 14-34-20-45.png deleted file mode 100644 index fbb87ef7..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-20-45.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-23-36.png b/screenshots/x/N++ 2017-06-15 14-34-23-36.png deleted file mode 100644 index 8a066d8c..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-23-36.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-25-41.png b/screenshots/x/N++ 2017-06-15 14-34-25-41.png deleted file mode 100644 index 2a15a9fe..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-25-41.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-27-29.png b/screenshots/x/N++ 2017-06-15 14-34-27-29.png deleted file mode 100644 index 36ef46d6..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-27-29.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-30-22.png b/screenshots/x/N++ 2017-06-15 14-34-30-22.png deleted file mode 100644 index 6d49b41b..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-30-22.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-32-30.png b/screenshots/x/N++ 2017-06-15 14-34-32-30.png deleted file mode 100644 index 945bbfcb..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-32-30.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-32-31.png b/screenshots/x/N++ 2017-06-15 14-34-32-31.png deleted file mode 100644 index 223d26e1..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-32-31.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-36-19.png b/screenshots/x/N++ 2017-06-15 14-34-36-19.png deleted file mode 100644 index c254308f..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-36-19.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-38-28.png b/screenshots/x/N++ 2017-06-15 14-34-38-28.png deleted file mode 100644 index fa7f1020..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-38-28.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-40-80.png b/screenshots/x/N++ 2017-06-15 14-34-40-80.png deleted file mode 100644 index 993b2cc0..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-40-80.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-42-64.png b/screenshots/x/N++ 2017-06-15 14-34-42-64.png deleted file mode 100644 index 5d334c09..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-42-64.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-44-71.png b/screenshots/x/N++ 2017-06-15 14-34-44-71.png deleted file mode 100644 index 9f02241e..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-44-71.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-46-90.png b/screenshots/x/N++ 2017-06-15 14-34-46-90.png deleted file mode 100644 index e3983595..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-46-90.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-49-02.png b/screenshots/x/N++ 2017-06-15 14-34-49-02.png deleted file mode 100644 index 72365047..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-49-02.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-53-88.png b/screenshots/x/N++ 2017-06-15 14-34-53-88.png deleted file mode 100644 index 09dc1d7a..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-53-88.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-55-97.png b/screenshots/x/N++ 2017-06-15 14-34-55-97.png deleted file mode 100644 index eb1db43f..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-55-97.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-57-90.png b/screenshots/x/N++ 2017-06-15 14-34-57-90.png deleted file mode 100644 index 98c5069d..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-57-90.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-34-57-91.png b/screenshots/x/N++ 2017-06-15 14-34-57-91.png deleted file mode 100644 index 96ca6e40..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-34-57-91.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-02-07.png b/screenshots/x/N++ 2017-06-15 14-35-02-07.png deleted file mode 100644 index d5bfed9f..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-02-07.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-04-55.png b/screenshots/x/N++ 2017-06-15 14-35-04-55.png deleted file mode 100644 index 77e8edf4..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-04-55.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-06-71.png b/screenshots/x/N++ 2017-06-15 14-35-06-71.png deleted file mode 100644 index fbba5c94..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-06-71.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-08-55.png b/screenshots/x/N++ 2017-06-15 14-35-08-55.png deleted file mode 100644 index a4edea62..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-08-55.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-10-37.png b/screenshots/x/N++ 2017-06-15 14-35-10-37.png deleted file mode 100644 index ec38efba..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-10-37.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-12-14.png b/screenshots/x/N++ 2017-06-15 14-35-12-14.png deleted file mode 100644 index f7b79ed3..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-12-14.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-14-80.png b/screenshots/x/N++ 2017-06-15 14-35-14-80.png deleted file mode 100644 index ed658060..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-14-80.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-16-63.png b/screenshots/x/N++ 2017-06-15 14-35-16-63.png deleted file mode 100644 index bcda6a62..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-16-63.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-18-65.png b/screenshots/x/N++ 2017-06-15 14-35-18-65.png deleted file mode 100644 index eb62cdf8..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-18-65.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-20-42.png b/screenshots/x/N++ 2017-06-15 14-35-20-42.png deleted file mode 100644 index 576fef7a..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-20-42.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-22-15.png b/screenshots/x/N++ 2017-06-15 14-35-22-15.png deleted file mode 100644 index fdb68e59..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-22-15.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-24-77.png b/screenshots/x/N++ 2017-06-15 14-35-24-77.png deleted file mode 100644 index 07a9f6d3..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-24-77.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-26-99.png b/screenshots/x/N++ 2017-06-15 14-35-26-99.png deleted file mode 100644 index f6269410..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-26-99.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-28-88.png b/screenshots/x/N++ 2017-06-15 14-35-28-88.png deleted file mode 100644 index 71594f25..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-28-88.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-30-61.png b/screenshots/x/N++ 2017-06-15 14-35-30-61.png deleted file mode 100644 index c330bf74..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-30-61.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-32-45.png b/screenshots/x/N++ 2017-06-15 14-35-32-45.png deleted file mode 100644 index 827dde65..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-32-45.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-34-89.png b/screenshots/x/N++ 2017-06-15 14-35-34-89.png deleted file mode 100644 index 40f809e4..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-34-89.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-36-71.png b/screenshots/x/N++ 2017-06-15 14-35-36-71.png deleted file mode 100644 index a4cc8a91..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-36-71.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-38-98.png b/screenshots/x/N++ 2017-06-15 14-35-38-98.png deleted file mode 100644 index 6d657039..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-38-98.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-40-87.png b/screenshots/x/N++ 2017-06-15 14-35-40-87.png deleted file mode 100644 index bc480a80..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-40-87.png and /dev/null differ diff --git a/screenshots/x/N++ 2017-06-15 14-35-42-88.png b/screenshots/x/N++ 2017-06-15 14-35-42-88.png deleted file mode 100644 index cda4d313..00000000 Binary files a/screenshots/x/N++ 2017-06-15 14-35-42-88.png and /dev/null differ diff --git a/screenshots/x/SU-X-00-00.jpg b/screenshots/x/SU-X-00-00.jpg deleted file mode 100644 index 1b585ab6..00000000 Binary files a/screenshots/x/SU-X-00-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-00-01.jpg b/screenshots/x/SU-X-00-01.jpg deleted file mode 100644 index c6a00009..00000000 Binary files a/screenshots/x/SU-X-00-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-00-02.jpg b/screenshots/x/SU-X-00-02.jpg deleted file mode 100644 index f1c78794..00000000 Binary files a/screenshots/x/SU-X-00-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-00-03.jpg b/screenshots/x/SU-X-00-03.jpg deleted file mode 100644 index e031dd17..00000000 Binary files a/screenshots/x/SU-X-00-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-00-04.jpg b/screenshots/x/SU-X-00-04.jpg deleted file mode 100644 index 9a169a1c..00000000 Binary files a/screenshots/x/SU-X-00-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-01-00.jpg b/screenshots/x/SU-X-01-00.jpg deleted file mode 100644 index 1f3c42ff..00000000 Binary files a/screenshots/x/SU-X-01-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-01-01.jpg b/screenshots/x/SU-X-01-01.jpg deleted file mode 100644 index 4d4d586b..00000000 Binary files a/screenshots/x/SU-X-01-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-01-02.jpg b/screenshots/x/SU-X-01-02.jpg deleted file mode 100644 index 6d232303..00000000 Binary files a/screenshots/x/SU-X-01-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-01-03.jpg b/screenshots/x/SU-X-01-03.jpg deleted file mode 100644 index d7a43f53..00000000 Binary files a/screenshots/x/SU-X-01-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-01-04.jpg b/screenshots/x/SU-X-01-04.jpg deleted file mode 100644 index 89c79649..00000000 Binary files a/screenshots/x/SU-X-01-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-02-00.jpg b/screenshots/x/SU-X-02-00.jpg deleted file mode 100644 index 781c8797..00000000 Binary files a/screenshots/x/SU-X-02-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-02-01.jpg b/screenshots/x/SU-X-02-01.jpg deleted file mode 100644 index 21799e4c..00000000 Binary files a/screenshots/x/SU-X-02-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-02-02.jpg b/screenshots/x/SU-X-02-02.jpg deleted file mode 100644 index 9c474ded..00000000 Binary files a/screenshots/x/SU-X-02-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-02-03.jpg b/screenshots/x/SU-X-02-03.jpg deleted file mode 100644 index 1a0307f1..00000000 Binary files a/screenshots/x/SU-X-02-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-02-04.jpg b/screenshots/x/SU-X-02-04.jpg deleted file mode 100644 index e31533dc..00000000 Binary files a/screenshots/x/SU-X-02-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-03-00.jpg b/screenshots/x/SU-X-03-00.jpg deleted file mode 100644 index e4206cb4..00000000 Binary files a/screenshots/x/SU-X-03-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-03-01.jpg b/screenshots/x/SU-X-03-01.jpg deleted file mode 100644 index f75f6678..00000000 Binary files a/screenshots/x/SU-X-03-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-03-02.jpg b/screenshots/x/SU-X-03-02.jpg deleted file mode 100644 index 8f6e5f07..00000000 Binary files a/screenshots/x/SU-X-03-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-03-03.jpg b/screenshots/x/SU-X-03-03.jpg deleted file mode 100644 index 139e3e95..00000000 Binary files a/screenshots/x/SU-X-03-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-03-04.jpg b/screenshots/x/SU-X-03-04.jpg deleted file mode 100644 index 7f6df3ca..00000000 Binary files a/screenshots/x/SU-X-03-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-04-00.jpg b/screenshots/x/SU-X-04-00.jpg deleted file mode 100644 index fdf7623c..00000000 Binary files a/screenshots/x/SU-X-04-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-04-01.jpg b/screenshots/x/SU-X-04-01.jpg deleted file mode 100644 index 2209abb6..00000000 Binary files a/screenshots/x/SU-X-04-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-04-02.jpg b/screenshots/x/SU-X-04-02.jpg deleted file mode 100644 index 9ce8c9b7..00000000 Binary files a/screenshots/x/SU-X-04-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-04-03.jpg b/screenshots/x/SU-X-04-03.jpg deleted file mode 100644 index 85082a6f..00000000 Binary files a/screenshots/x/SU-X-04-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-04-04.jpg b/screenshots/x/SU-X-04-04.jpg deleted file mode 100644 index 81e99dd0..00000000 Binary files a/screenshots/x/SU-X-04-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-05-00.jpg b/screenshots/x/SU-X-05-00.jpg deleted file mode 100644 index e40e26b2..00000000 Binary files a/screenshots/x/SU-X-05-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-05-01.jpg b/screenshots/x/SU-X-05-01.jpg deleted file mode 100644 index 102bf077..00000000 Binary files a/screenshots/x/SU-X-05-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-05-02.jpg b/screenshots/x/SU-X-05-02.jpg deleted file mode 100644 index 53e5a130..00000000 Binary files a/screenshots/x/SU-X-05-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-05-03.jpg b/screenshots/x/SU-X-05-03.jpg deleted file mode 100644 index 49b28b56..00000000 Binary files a/screenshots/x/SU-X-05-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-05-04.jpg b/screenshots/x/SU-X-05-04.jpg deleted file mode 100644 index 8735f9a1..00000000 Binary files a/screenshots/x/SU-X-05-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-06-00.jpg b/screenshots/x/SU-X-06-00.jpg deleted file mode 100644 index ea72aa32..00000000 Binary files a/screenshots/x/SU-X-06-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-06-01.jpg b/screenshots/x/SU-X-06-01.jpg deleted file mode 100644 index 0d0bcd84..00000000 Binary files a/screenshots/x/SU-X-06-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-06-02.jpg b/screenshots/x/SU-X-06-02.jpg deleted file mode 100644 index 6ccb9f48..00000000 Binary files a/screenshots/x/SU-X-06-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-06-03.jpg b/screenshots/x/SU-X-06-03.jpg deleted file mode 100644 index 34316e25..00000000 Binary files a/screenshots/x/SU-X-06-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-06-04.jpg b/screenshots/x/SU-X-06-04.jpg deleted file mode 100644 index c8d3b042..00000000 Binary files a/screenshots/x/SU-X-06-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-07-00.jpg b/screenshots/x/SU-X-07-00.jpg deleted file mode 100644 index eccc97ff..00000000 Binary files a/screenshots/x/SU-X-07-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-07-01.jpg b/screenshots/x/SU-X-07-01.jpg deleted file mode 100644 index 03c6e9c6..00000000 Binary files a/screenshots/x/SU-X-07-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-07-02.jpg b/screenshots/x/SU-X-07-02.jpg deleted file mode 100644 index 06e43e8d..00000000 Binary files a/screenshots/x/SU-X-07-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-07-03.jpg b/screenshots/x/SU-X-07-03.jpg deleted file mode 100644 index 1b481020..00000000 Binary files a/screenshots/x/SU-X-07-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-07-04.jpg b/screenshots/x/SU-X-07-04.jpg deleted file mode 100644 index 3e123892..00000000 Binary files a/screenshots/x/SU-X-07-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-08-00.jpg b/screenshots/x/SU-X-08-00.jpg deleted file mode 100644 index 50cd5ae0..00000000 Binary files a/screenshots/x/SU-X-08-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-08-01.jpg b/screenshots/x/SU-X-08-01.jpg deleted file mode 100644 index df4da23b..00000000 Binary files a/screenshots/x/SU-X-08-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-08-02.jpg b/screenshots/x/SU-X-08-02.jpg deleted file mode 100644 index e6e85f11..00000000 Binary files a/screenshots/x/SU-X-08-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-08-03.jpg b/screenshots/x/SU-X-08-03.jpg deleted file mode 100644 index 948e8e8a..00000000 Binary files a/screenshots/x/SU-X-08-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-08-04.jpg b/screenshots/x/SU-X-08-04.jpg deleted file mode 100644 index fe15ebbd..00000000 Binary files a/screenshots/x/SU-X-08-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-09-00.jpg b/screenshots/x/SU-X-09-00.jpg deleted file mode 100644 index c1c179ce..00000000 Binary files a/screenshots/x/SU-X-09-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-09-01.jpg b/screenshots/x/SU-X-09-01.jpg deleted file mode 100644 index 67f4f787..00000000 Binary files a/screenshots/x/SU-X-09-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-09-02.jpg b/screenshots/x/SU-X-09-02.jpg deleted file mode 100644 index cfc3803b..00000000 Binary files a/screenshots/x/SU-X-09-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-09-03.jpg b/screenshots/x/SU-X-09-03.jpg deleted file mode 100644 index 8e59c690..00000000 Binary files a/screenshots/x/SU-X-09-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-09-04.jpg b/screenshots/x/SU-X-09-04.jpg deleted file mode 100644 index 78865584..00000000 Binary files a/screenshots/x/SU-X-09-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-10-00.jpg b/screenshots/x/SU-X-10-00.jpg deleted file mode 100644 index 1e7f59c4..00000000 Binary files a/screenshots/x/SU-X-10-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-10-01.jpg b/screenshots/x/SU-X-10-01.jpg deleted file mode 100644 index 1ca2f582..00000000 Binary files a/screenshots/x/SU-X-10-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-10-02.jpg b/screenshots/x/SU-X-10-02.jpg deleted file mode 100644 index 60f2968d..00000000 Binary files a/screenshots/x/SU-X-10-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-10-03.jpg b/screenshots/x/SU-X-10-03.jpg deleted file mode 100644 index 5cfdfd34..00000000 Binary files a/screenshots/x/SU-X-10-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-10-04.jpg b/screenshots/x/SU-X-10-04.jpg deleted file mode 100644 index c2168ae3..00000000 Binary files a/screenshots/x/SU-X-10-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-11-00.jpg b/screenshots/x/SU-X-11-00.jpg deleted file mode 100644 index 4300c83e..00000000 Binary files a/screenshots/x/SU-X-11-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-11-01.jpg b/screenshots/x/SU-X-11-01.jpg deleted file mode 100644 index 459ffd9d..00000000 Binary files a/screenshots/x/SU-X-11-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-11-02.jpg b/screenshots/x/SU-X-11-02.jpg deleted file mode 100644 index 274a281f..00000000 Binary files a/screenshots/x/SU-X-11-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-11-03.jpg b/screenshots/x/SU-X-11-03.jpg deleted file mode 100644 index 27b33920..00000000 Binary files a/screenshots/x/SU-X-11-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-11-04.jpg b/screenshots/x/SU-X-11-04.jpg deleted file mode 100644 index d1d5a9ff..00000000 Binary files a/screenshots/x/SU-X-11-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-12-00.jpg b/screenshots/x/SU-X-12-00.jpg deleted file mode 100644 index d4abe8ca..00000000 Binary files a/screenshots/x/SU-X-12-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-12-01.jpg b/screenshots/x/SU-X-12-01.jpg deleted file mode 100644 index 92c251de..00000000 Binary files a/screenshots/x/SU-X-12-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-12-02.jpg b/screenshots/x/SU-X-12-02.jpg deleted file mode 100644 index 0a82ab4e..00000000 Binary files a/screenshots/x/SU-X-12-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-12-03.jpg b/screenshots/x/SU-X-12-03.jpg deleted file mode 100644 index dbc26c18..00000000 Binary files a/screenshots/x/SU-X-12-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-12-04.jpg b/screenshots/x/SU-X-12-04.jpg deleted file mode 100644 index 20d702e3..00000000 Binary files a/screenshots/x/SU-X-12-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-13-00.jpg b/screenshots/x/SU-X-13-00.jpg deleted file mode 100644 index 3074a109..00000000 Binary files a/screenshots/x/SU-X-13-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-13-01.jpg b/screenshots/x/SU-X-13-01.jpg deleted file mode 100644 index b2d635fe..00000000 Binary files a/screenshots/x/SU-X-13-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-13-02.jpg b/screenshots/x/SU-X-13-02.jpg deleted file mode 100644 index 2411ea66..00000000 Binary files a/screenshots/x/SU-X-13-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-13-03.jpg b/screenshots/x/SU-X-13-03.jpg deleted file mode 100644 index 637da5d2..00000000 Binary files a/screenshots/x/SU-X-13-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-13-04.jpg b/screenshots/x/SU-X-13-04.jpg deleted file mode 100644 index 7106cec1..00000000 Binary files a/screenshots/x/SU-X-13-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-14-00.jpg b/screenshots/x/SU-X-14-00.jpg deleted file mode 100644 index 84739d3e..00000000 Binary files a/screenshots/x/SU-X-14-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-14-01.jpg b/screenshots/x/SU-X-14-01.jpg deleted file mode 100644 index 1e35c6db..00000000 Binary files a/screenshots/x/SU-X-14-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-14-02.jpg b/screenshots/x/SU-X-14-02.jpg deleted file mode 100644 index 1e2ea73e..00000000 Binary files a/screenshots/x/SU-X-14-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-14-03.jpg b/screenshots/x/SU-X-14-03.jpg deleted file mode 100644 index b0c2c653..00000000 Binary files a/screenshots/x/SU-X-14-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-14-04.jpg b/screenshots/x/SU-X-14-04.jpg deleted file mode 100644 index 37697a87..00000000 Binary files a/screenshots/x/SU-X-14-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-15-00.jpg b/screenshots/x/SU-X-15-00.jpg deleted file mode 100644 index a657acc3..00000000 Binary files a/screenshots/x/SU-X-15-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-15-01.jpg b/screenshots/x/SU-X-15-01.jpg deleted file mode 100644 index d6e8622a..00000000 Binary files a/screenshots/x/SU-X-15-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-15-02.jpg b/screenshots/x/SU-X-15-02.jpg deleted file mode 100644 index e806ceda..00000000 Binary files a/screenshots/x/SU-X-15-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-15-03.jpg b/screenshots/x/SU-X-15-03.jpg deleted file mode 100644 index 6692ff31..00000000 Binary files a/screenshots/x/SU-X-15-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-15-04.jpg b/screenshots/x/SU-X-15-04.jpg deleted file mode 100644 index c4d25301..00000000 Binary files a/screenshots/x/SU-X-15-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-16-00.jpg b/screenshots/x/SU-X-16-00.jpg deleted file mode 100644 index 9b4b3702..00000000 Binary files a/screenshots/x/SU-X-16-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-16-01.jpg b/screenshots/x/SU-X-16-01.jpg deleted file mode 100644 index 1b355bbf..00000000 Binary files a/screenshots/x/SU-X-16-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-16-02.jpg b/screenshots/x/SU-X-16-02.jpg deleted file mode 100644 index 30166be1..00000000 Binary files a/screenshots/x/SU-X-16-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-16-03.jpg b/screenshots/x/SU-X-16-03.jpg deleted file mode 100644 index 425bca24..00000000 Binary files a/screenshots/x/SU-X-16-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-16-04.jpg b/screenshots/x/SU-X-16-04.jpg deleted file mode 100644 index 1e85a7d4..00000000 Binary files a/screenshots/x/SU-X-16-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-17-00.jpg b/screenshots/x/SU-X-17-00.jpg deleted file mode 100644 index d8678886..00000000 Binary files a/screenshots/x/SU-X-17-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-17-01.jpg b/screenshots/x/SU-X-17-01.jpg deleted file mode 100644 index 83befc47..00000000 Binary files a/screenshots/x/SU-X-17-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-17-02.jpg b/screenshots/x/SU-X-17-02.jpg deleted file mode 100644 index 7b2875c5..00000000 Binary files a/screenshots/x/SU-X-17-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-17-03.jpg b/screenshots/x/SU-X-17-03.jpg deleted file mode 100644 index cdb97ced..00000000 Binary files a/screenshots/x/SU-X-17-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-17-04.jpg b/screenshots/x/SU-X-17-04.jpg deleted file mode 100644 index ea46c167..00000000 Binary files a/screenshots/x/SU-X-17-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-18-00.jpg b/screenshots/x/SU-X-18-00.jpg deleted file mode 100644 index 2226bb22..00000000 Binary files a/screenshots/x/SU-X-18-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-18-01.jpg b/screenshots/x/SU-X-18-01.jpg deleted file mode 100644 index 09a07f98..00000000 Binary files a/screenshots/x/SU-X-18-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-18-02.jpg b/screenshots/x/SU-X-18-02.jpg deleted file mode 100644 index 1acba83d..00000000 Binary files a/screenshots/x/SU-X-18-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-18-03.jpg b/screenshots/x/SU-X-18-03.jpg deleted file mode 100644 index 1e0d29ce..00000000 Binary files a/screenshots/x/SU-X-18-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-18-04.jpg b/screenshots/x/SU-X-18-04.jpg deleted file mode 100644 index 4ee0b490..00000000 Binary files a/screenshots/x/SU-X-18-04.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-19-00.jpg b/screenshots/x/SU-X-19-00.jpg deleted file mode 100644 index 59d68dc8..00000000 Binary files a/screenshots/x/SU-X-19-00.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-19-01.jpg b/screenshots/x/SU-X-19-01.jpg deleted file mode 100644 index 5b57df62..00000000 Binary files a/screenshots/x/SU-X-19-01.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-19-02.jpg b/screenshots/x/SU-X-19-02.jpg deleted file mode 100644 index bdff4749..00000000 Binary files a/screenshots/x/SU-X-19-02.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-19-03.jpg b/screenshots/x/SU-X-19-03.jpg deleted file mode 100644 index 998b58fc..00000000 Binary files a/screenshots/x/SU-X-19-03.jpg and /dev/null differ diff --git a/screenshots/x/SU-X-19-04.jpg b/screenshots/x/SU-X-19-04.jpg deleted file mode 100644 index eab162ee..00000000 Binary files a/screenshots/x/SU-X-19-04.jpg and /dev/null differ diff --git a/src/admin.rb b/src/admin.rb new file mode 100644 index 00000000..c42a1575 --- /dev/null +++ b/src/admin.rb @@ -0,0 +1,1628 @@ +# This file contains all of the bot's special commands. These are managerial +# commands that can only be executed by the botmaster in DM's, and perform +# sensitive tasks such as giving system info, sanitizing the database, etc. +# See the method "respond_special" at the end to understand the flow. + +$load_time = Time.now + +require 'gruff' + +def send_test(event) +end + +def send_admin_panel(event) + view = Discordrb::Webhooks::View.new + view.row{ |row| + row.button(label: 'Config log', custom_id: 'admin:logconf', style: :secondary) + } + send_message(event, components: view) +end + +def admin_logconf(event) + event.show_modal(title: 'Configure logs', custom_id: 'admin:logconf') do |modal| + + # Radio buttons to specify the logging level + modal.label(id: 'label_level', label: 'Level') do |label| + label.radio_group(custom_id: 'level', required: false) do |group| + Log::LEVELS.each do |name, modes| + group.radio_button(value: name.to_s, label: name.to_s.capitalize, default: modes.sort == Log.modes.sort) + end + end + end + + # Checkboxes to specify logging modes individually + modal.label(id: 'label_modes', label: 'Modes') do |label| + label.checkbox_group(custom_id: 'modes', required: false) do |group| + Log::MODES.each do |name, att| + group.checkbox(value: name.to_s, label: name.to_s.capitalize, default: Log.modes.include?(name)) + end + end + end + + # Checkboxes for additional properties + modal.label(id: 'label_settings', label: 'Other settings') do |label| + label.checkbox_group(custom_id: 'settings', required: false) do |group| + group.checkbox( + value: 'socket', + label: 'Log socket activity', + description: 'Log incoming requests and outgoing responses', + default: Log.socket + ) + group.checkbox( + value: 'fancy', + label: 'Fancy logs', + description: 'Unicode colorful logs for the terminal', + default: Log.fancy + ) + end + end + end +end + +def modal_logconf(event) + changes = parse_modal_values(event).map{ |name, value| + next if value.nil? + case name + when 'level' + Log.level(value.to_sym) + when 'modes' + Log.set_modes(value.map(&:to_sym)) + when 'settings' + [ + Log.change_socket(value.include?('socket')), + Log.change_fancy(value.include?('fancy')) + ] + end + }.compact.flatten.map{ |line| "* #{line}." }.join("\n") + event.respond(content: "### __Modified log configuration__:\n#{changes}") +end + +# Clean database (remove cheated archives, duplicates, orphaned demos, etc) +# See Archive::sanitize for more details +def sanitize_archives(event) + assert_permissions(event) + counts = Archive::sanitize + if counts.empty? + event << "Nothing to sanitize." + return + end + event << "Sanitized database:" + counts.each{ |name, msg| event << "* #{msg}" } +rescue => e + lex(e, "Error sanitizing archives.", event: event) +end + +def sanitize_userlevels(event) + # Delete scores by ignored players + query = UserlevelScore.joins("INNER JOIN `userlevel_players` ON `userlevel_players`.`id` = `userlevel_scores`.`player_id`") + .where("userlevel_players.metanet_id" => HACKERS.keys + CHEATERS.keys) + count = query.count.to_i + query.delete_all + event << "* Deleted #{count} illegitimate userlevel scores." unless count == 0 + + # Delete individual incorrect scores + query = UserlevelScore.where(replay_id: PATCH_IND_DEL[:userlevel]) + count = query.count.to_i + query.delete_all + event << "* Deleted #{count} incorrect userlevel scores." unless count == 0 + + # Delete ignored players + query = UserlevelPlayer.where(metanet_id: HACKERS.keys + CHEATERS.keys) + count = query.count.to_i + event << "* Deleted #{count} userlevel hackers." unless count == 0 + query.delete_all + + event << "Done" +end + +def send_pool_test + workers = ThreadPool.new(10) + tasks = 50.times.map{ |i| + Proc.new do |thread:| + length = 5 * rand + times = 3.times.map{ length * rand }.sort + waits = times.map.with_index{ |t, i| (times[i + 1] || length) - t }.prepend(times[0]) + waits.inject(0){ |sum, w| + sleep(w) + thread.progress = 100.0 * (sum + w) / length + sum + w + } + thread.pool.report(Log.format("Finished task #{i}", :succ)[:fancy]) + end + } + tasks.each{ |task| workers.schedule(task) } + workers.close + while workers.alive + sleep(0.2) + workers.log + end +end + +def scan_boards(event = nil, page: nil) + workers = ThreadPool.new(20) + levels = Level.where(tab: :S).order(:id).offset(400).limit(100).all + player = Player.find_by(metanet_id: OUTTE2_ID) + tasks = levels.map{ |h| + Proc.new do |thread:| + thread.status = h.name + sleep(rand) + case player.get_pb(h) + when :inactive, :error + sleep(10) + raise + when :none + h.scan_boards(worker: thread) + else + thread.pool.report(Log.format("#{h.name} already has a score, skipping", :warn)[:fancy]) + end + rescue + retry + end + } + tasks.each{ |task| workers.schedule(task) } + workers.close + while workers.alive + sleep(0.1) + workers.log + end + succ("Finished scanning #{levels.size} boards (#{levels.first.name} to #{levels.last.name})") +end + +def send_dday_stats(event) + flags = parse_flags(event) + year = flags[:year] || Time.now.year + maps = Userlevel.joins("INNER JOIN `userlevel_authors` ON `userlevel_authors`.`id` = `author_id`") + .where(date: Time.utc(year, 7, 4) .. Time.utc(year, 7, 4, 23, 59, 59)) + data = maps.pluck(:date, :author_id, '`name`') + count = data.size + times = data.group_by{ |date, author, name| date.hour } + .map{ |hour, list| list.count } + sums = times.inject([0]){ |memo, count| memo << memo.last.to_i + count } + authors_all = data.group_by{ |date, author, name| author } + .map{ |author, list| [list[0][2].empty? ? 'ID:' + list[0][1].to_s : list[0][2], list.count] } + .sort_by{ |name, count| -count } + authors = authors_all.take(10) + authors.prepend(["Others", authors_all.map(&:last).sum - authors.map(&:last).sum]) + authors_by_date_all = data.group_by{ |date, author, name| author } + .map{ |author, list| + by_hour = 24.times.map{ |h| [h, 0] }.to_h + list.each{ |date, author, name| by_hour[date.hour] += 1 } + [list[0][2].empty? ? 'ID:' + list[0][1].to_s : list[0][2], by_hour] + }.sort_by{ |author, list| -list.values.sum } + authors_by_date = authors_by_date_all.take(10) + by_hour = 24.times.map{ |h| [h, 0] }.to_h + authors_by_date_all[10..].each{ |author, list| + list.each{ |hour, count| by_hour[hour] += count } + } + authors_by_date.prepend(["Others", by_hour]) + authors_cum = authors_by_date.map{ |name, list| + asums = list.inject([0]){ |memo, (hour, count)| memo << memo.last.to_i + count } + [name, asums] + } + n = 8 + graphs = [] + generate = Proc.new do |fn, klass, lab=true, &block| + dbg("Rendering #{fn}...") + g = klass.new + block[g] + g.labels = (n + 1).times.map{ |i| [3 * i, (3 * i).to_s] }.to_h if lab + g.write(fn) + graphs << fn + rescue => e + lex(e, "Failed to render #{fn}") + end + + # Maps per hour + generate['rate.png', Gruff::Line] do |g| + g.title = "Maps per hour (#{count} total)" + g.data('Maps', [0] + times) + g.show_vertical_markers = true + g.marker_x_count = n + g.hide_dots = true + g.hide_legend = true + end + + # Cumulative maps per hour + generate['cum.png', Gruff::Line] do |g| + g.title = "Cumulative maps per hour (#{count} total)" + g.data('Maps', sums) + g.hide_dots = true + g.hide_legend = true + end + + # Histogram by author + generate['bar.png', Gruff::SideBar, false] do |g| + g.title = 'Histogram Graph' + g.labels = authors.map.with_index{ |a, i| [i, a[0]] }.to_h + g.data(:Maps, authors.map(&:last)) + g.marker_count = n + end + + # Pie chart + generate['pie.png', Gruff::Pie, false] do |g| + g.title = "Author distribution (100% total)" + authors.each{ |name, count| g.data(name.to_sym, [count]) } + end + + # Stacked area + generate['stacked_rate.png', Gruff::StackedArea] do |g| + g.title = "Author maps per hour (#{count} total)" + authors_by_date.each{ |name, list| g.data(name.to_sym, list.values) } + g.marker_count = n + end + + # Cumulative stacked area + generate['stacked_cum.png', Gruff::StackedArea] do |g| + g.title = "Cumulative author maps per hour (#{count} total)" + authors_cum.each{ |name, list| g.data(name.to_sym, list) } + g.marker_count = n + end + + # Format message + list = authors_all.map{ |name, count| "%16s - %3d" % [name, count] }.join("\n") + files = graphs.map{ |fn| File.open(fn) } + [tmp_file(list, 'dday_authors.txt')] + send_message(event, files: files) + FileUtils.rm_rf(graphs) +end + +def send_color_test(event) + fg_colors = [ + nil, + ANSI::BLACK, ANSI::RED, ANSI::GREEN, ANSI::YELLOW, + ANSI::BLUE, ANSI::MAGENTA, ANSI::CYAN, ANSI::WHITE + ] + bg_colors = [ + nil, + ANSI::BLACK_BG, ANSI::RED_BG, ANSI::GREEN_BG, ANSI::YELLOW_BG, + ANSI::BLUE_BG, ANSI::MAGENTA_BG, ANSI::CYAN_BG, ANSI::WHITE_BG + ] + res = fg_colors.map{ |fg| + bg_colors.map{ |bg| + ANSI.format("TEST", bold: true, fg: fg, bg: bg) + }.join(' ') + }.join("\n") + event << format_block(res) +end + +def send_reaction(event) + flags = parse_flags(event) + react(flags[:c], flags[:m], flags[:r]) +rescue => e + lex(e, "Error sending reaction.", event: event) +end + +def send_unreaction(event) + flags = parse_flags(event) + unreact(flags[:c], flags[:m], flags[:r]) +rescue => e + lex(e, "Error removing reaction.", event: event) +end + +def send_mappack_seed(event) + flags = parse_flags(event) + update = flags.key?(:update) + hard = flags.key?(:hard) + all = flags.key?(:all) + Mappack.seed(update: update, hard: hard, all: all) + event << "Seeded new mappacks, there're now #{Mappack.count}." +rescue => e + lex(e, "Error seeding new mappacks.", event: event) +end + +def send_mappack_update(event) + flags = parse_flags(event) + mappack = parse_mappack(flags[:mappack], explicit: true, vanilla: false) + perror("Mappack not found.") if mappack.nil? + version = flags.key?(:version) ? flags[:version].to_i : mappack.version + hard = flags.key?(:hard) + name = "#{hard ? 'hard' : 'soft'} update for mappack #{mappack.code.upcase} v#{version}" + send_message(event, content: "Performing #{name}.") + mappack.read(v: version, hard: hard) + event << "Finished #{name}." +rescue => e + lex(e, "Error updating mappack.", event: event) +end + +def send_mappack_patch(event) + flags = parse_flags(event) + if flags.key?(:all) + wrong = MappackScore.gold_check(mappack: flags.key?(:m)) + count = wrong.count + changed = wrong.count{ |s| + !!MappackScore.patch_score(s[2], nil, nil, nil, silent: true) + } + Log.clear + event << "Patched #{changed} / #{count} mappack scores successfully with ntrace." + else + id = flags[:id] + highscoreable = parse_highscoreable(event, mappack: true) if !id + player = parse_player(event, false, true, true, flag: :p) if !id + score = parse_score(flags[:s]) if flags.key?(:s) + output = '' + MappackScore.patch_score(id, highscoreable, player, score, output: output) + event << output + end +rescue => e + lex(e, "Error patching mappack score.", event: event) +end + +def send_mappack_ranks(event) + flags = parse_flags(event) + h = parse_highscoreable(event, mappack: true) + board = parse_board(flags[:b]) + perror("Only the hs/sr ranks can be updated") if !['hs', 'sr', nil].include?(board) + frac = h.mappack.fractional && h.is_level? + h.update_ranks('hs', frac: frac) if board == 'hs' || board.nil? + h.update_ranks('sr', frac: frac) if board == 'sr' || board.nil? + board = "hs & sr" if board.nil? + event << "Updated #{board} ranks for #{h.name}" +rescue => e + lex(e, "Error updating ranks.", event: event) +end + +def send_mappack_info(event) + flags = parse_flags(event) + mappack = parse_mappack(flags[:mappack], explicit: true, vanilla: false, priv: true) + perror("You need to provide a mappack.") if !mappack + channels = flags[:channels].split.map(&:strip) if flags.key?(:channels) + enabled = flags.key?(:enabled) ? true : flags.key?(:disabled) ? false : nil + public = flags.key?(:public) ? true : flags.key?(:private) ? false : nil + fractional = flags.key?(:fractional) ? true : flags.key?(:integer) ? false : nil + mappack.set_info( + name: flags[:name], author: flags[:author], date: flags[:date], + channel: channels, version: flags[:version], enabled: enabled, + public: public, fractional: fractional + ) + flags.delete(:mappack) + flags = flags.map{ |k, v| "#{k} to #{verbatim(v)}" unless v.nil? }.compact.to_sentence + event << "Set mappack #{verbatim(mappack.code)} #{flags}." +rescue => e + lex(e, "Error setting mappack's info.", event: event) +end + +def send_mappack_digest(event) + Mappack.digest + event << "Updated the mappack digest, #{Mappack.all.count} mappacks found" +rescue => e + lex(e, "Error updating the mappack digest.", event: event) +end + +def send_ul_csv(event) + send_file(event, Userlevel.dump_csv, 'userlevels.csv') +rescue => e + lex(e, "Error preparing userlevel CSV.", event: event) +end + +def send_mappack_completions(event) + flags = parse_flags(event) + mappack = parse_mappack(flags[:mappack], explicit: true, vanilla: false) + MappackScore.update_completions(mappack: mappack) + event << "Updated #{mappack ? mappack.code.upcase + ' ' : ''}mappack completions." +rescue => e + lex(e, "Error updating mappack completions.", event: event) +end + +def send_mappack_authors(event) + flags = parse_flags(event) + mappack = parse_mappack(flags[:mappack], explicit: true, vanilla: false) + perror("Mappack not found") if !mappack + mappack.read_authors(manual: true) + event << "Read #{mappack.code.upcase} mappack authors." +rescue => e + lex(e, "Error reading mappack authors.", event: event) +end + +def send_mappack_scores(event) + flags = parse_flags(event) + mappack = parse_mappack(flags[:mappack], explicit: true, vanilla: false) + perror("Mappack not found") if !mappack + mappack.read_scores(manual: true) + event << "Read #{mappack.code.upcase} mappack scores." +rescue => e + lex(e, "Error reading mappack scores.", event: event) +end + +def send_mappack_challenges(event) + flags = parse_flags(event) + mappack = parse_mappack(flags[:mappack], explicit: true, vanilla: false) + perror("Mappack not found") if !mappack + mappack.read_challenges(manual: true) + event << "Read #{mappack.code.upcase} mappack challenges." +rescue => e + lex(e, "Error reading mappack challenges.", event: event) +end + +def send_mappack_userlevels(event) + flags = parse_flags(event) + mappack = parse_mappack(flags[:mappack], explicit: true, vanilla: false) + perror("Mappack not found") if !mappack + should_seed = flags.key?(:seed) && !(flags.key?(:start) && flags.key?(:stop)) + perror("You need to provide a time range (with `-start` and `-stop`) in order to seed") if should_seed + start = Time.find_zone("UTC").parse(flags[:start]) if flags.key?(:start) + stop = Time.find_zone("UTC").parse(flags[:stop]) if flags.key?(:stop) + content = "" + files = [] + + # Find matching userlevels + if flags.key?(:seed) + mode = flags[:mode].to_i + mappack.seed_userlevels(mode: mode, start: start, stop: stop, min_id: flags[:min_id], max_id: flags[:max_id]) + content << "Seeded userlevel forwards. " + end + + # Manually add matches + if flags.key?(:enable) + ids = flags[:enable].split(' ') + mappack.levels.where(userlevel_id: ids).update_all(forward: true) + end + + # Manually remove matches + if flags.key?(:disable) + ids = flags[:disable].split(' ') + mappack.levels.where(userlevel_id: ids).update_all(forward: false) + end + + # Add automatic gold corrections. It assumes all added gold is forced, and + # all removed gold is impossible. + if flags.key?(:gold) + list = mappack.nonmatching_userlevels(start: start, stop: stop) + list.each_with_index{ |l, i| + dbg("Computing object differences for level #{i + 1} / #{list.length}...", progress: true) + add_objects = l.normalized_objects - l.userlevel.normalized_objects + del_objects = l.userlevel.normalized_objects - l.normalized_objects + next if (add_objects | del_objects).map(&:first).any?{ |id| id != Map::ID_GOLD } + l.update(forward: true) + next if add_objects.empty? + ScoreCorrection.find_or_create_by(highscoreable: l).update(gold: add_objects.size) + } + end + + # Find list of non-identical matches + if flags.key?(:errors) + screenies = flags.key?(:screenshots) + levels = [] + texts = [] + list = mappack.nonmatching_userlevels(start: start, stop: stop) + list = list.map.with_index{ |l, i| + dbg("Computing distance for level #{i + 1} / #{list.length}...", progress: true) + u = l.userlevel + [l, u, l.distance(u)] + }.sort_by{ |level, userlevel, distance| -distance }.map{ |l, u, d| + levels << u << l if screenies + texts << "ID: #{u.id}\n#{u.title}" << "#{l.name}\nDistance: #{d}" if screenies + '%-14s (%4d): %6d (%s)' % [l.name, d, u.id, u.title] + } + png = Map.screenshot(h: levels, titles: texts) if screenies + files << tmp_file(list.join("\n"), 'list.txt') + files << tmp_file(png, 'screenies.png', binary: true) if screenies + end + + # Send mappack userlevel status + total = mappack.levels.count + found = mappack.levels.where.not(userlevel: nil).count + forwarded = mappack.levels.where(forward: true).count + content << "Found #{found} / #{total} matches (#{forwarded} forwarded)" + + send_message(event, content: content, files: files) +rescue => e + lex(e, "Error reading mappack challenges.", event: event) +end + +def send_highscore_plot(event) + flags = parse_flags(event) + mappack = parse_mappack(flags[:mappack], explicit: true, vanilla: false) + + counts = MappackScore.where('id > ?', MIN_REPLAY_ID) + counts = counts.where(mappack: mappack) if mappack + total_counts = counts.group('date(date)').count + dates = (total_counts.keys.first .. total_counts.keys.last).to_a + total_counts = dates.map{ |date| total_counts[date].to_i } + + labels = dates.map{ |date| + [1, 10, 20].include?(date.day) ? date.strftime("%b %d") : '' + } + + create_svg( + filename: "#{mappack ? mappack.code : 'mappack'}_highscores_by_day.svg", + title: "#{mappack ? mappack.code.upcase : 'Mappack'} highscores by day\n (Total: #{total_counts.sum} highscores in #{total_counts.size} days)", + x_name: 'Date', + y_name: 'Count', + x_res: 1000, + y_res: 500, + data: [total_counts], + names: ['Highscores'], + labels: labels, + fmt: '%d' + ) + event << 'Generated highscore plot.' +rescue => e + lex(e, "Error generating highscore plot.", event: event) +end + +def send_ul_plot_day(event, scores = false) + if scores + list = UserlevelScore.joins("INNER JOIN userlevels ON userlevels.id = userlevel_id") + else + list = Userlevel + end + counts = list.group('date(date)').count + dates = (counts.keys.first .. counts.keys.last).to_a + + total_counts = dates.map{ |date| counts[date].to_i } + dalton_counts = list.where('author_id = 234533').group('date(date)').count + dalton_counts = dates.map{ |date| dalton_counts[date].to_i } + + labels = dates.map{ |date| + [1, 7].include?(date.month) && date.day == 1 ? date.strftime("%b '%y") : '' + } + + title = scores ? 'Userlevel scores' : 'Userlevels' + create_svg( + title: "#{title} by day\n (Total: #{total_counts.sum} userlevels in #{total_counts.size} days)", + x_name: 'Date', + y_name: 'Count', + x_res: 3000, + y_res: 500, + data: [dalton_counts, total_counts], + names: ['Dalton', 'Total'], + labels: labels, + fmt: '%d' + ) +end + +def send_ul_plot_month(event, scores = false) + if scores + list = UserlevelScore.joins("INNER JOIN userlevels ON userlevels.id = userlevel_id") + else + list = Userlevel + end + counts = list.group('year(date)', 'month(date)').count + first_year = counts.keys.first[0] + last_year = counts.keys.last[0] + first_month = counts.keys.first[1] + last_month = counts.keys.last[1] + + total_counts = (first_year .. last_year).map{ |year| + month1 = year == first_year ? first_month : 1 + month2 = year == last_year ? last_month : 12 + (month1 .. month2).map{ |month| + counts[[year, month]].to_i + } + }.flatten + + dalton_counts = list.where('author_id = 234533').group('year(date)', 'month(date)').count + dalton_counts = (first_year .. last_year).map{ |year| + month1 = year == first_year ? first_month : 1 + month2 = year == last_year ? last_month : 12 + (month1 .. month2).map{ |month| + dalton_counts[[year, month]].to_i + } + }.flatten + + labels = (first_year .. last_year).map{ |year| + month1 = year == first_year ? first_month : 1 + month2 = year == last_year ? last_month : 12 + (month1 .. month2).map{ |month| + case month + when 1 + "Jan '#{year % 100}" + when 7 + "Jul '#{year % 100}" + else + '' + end + } + }.flatten + + title = scores ? 'Userlevel scores' : 'Userlevels' + create_svg( + title: "#{title} by month\n (Total: #{total_counts.sum} userlevels in #{total_counts.size} months)", + x_name: 'Date', + y_name: 'Count', + x_res: 1920, + y_res: 500, + data: [dalton_counts, total_counts], + names: ['Dalton', 'Total'], + labels: labels, + fmt: '%d' + ) +end + +def send_ul_plot(event) + flags = parse_flags(event) + case flags[:period] + when 'month' + file = send_ul_plot_month(event, flags.key?(:scores)) + else + file = send_ul_plot_day(event, flags.key?(:scores)) + end + send_file(event, file, 'plot.svg', true) +rescue => e + lex(e, "Error generating userlevel plot.", event: event) +end + +def send_gold_check(event) + flags = parse_flags(event) + id = [flags[:id].to_i, MIN_REPLAY_ID].max + mappack = parse_mappack(flags[:mappack], explicit: true, vanilla: false) + strict = flags.key?(:strict) + event << "List of potentially incorrect mappack scores:" + rows = [] + rows << ['Level', 'Player', 'ID', 'Current', 'HS', 'SR', 'Gold'] + rows << :sep + rows.push(*MappackScore.gold_check(id: id, mappack: mappack, strict: strict)) + rows.size > 24 ? send_file(event, make_table(rows), 'gold_check.txt') : event << format_block(make_table(rows)) +rescue => e + lex(e, "Error performing gold check.", event: event) +end + +def fill_gold_counts(event) + level_count = MappackLevel.count + episode_count = MappackEpisode.count + story_count = MappackStory.count + MappackLevel.find_each.with_index{ |l, i| + dbg("Setting gold count for level #{i + 1} / #{level_count}...", progress: true) + l.update(gold: l.gold) + } + Log.clear + MappackEpisode.find_each.with_index{ |e, i| + dbg("Setting gold count for episode #{i + 1} / #{episode_count}...", progress: true) + e.update(gold: MappackLevel.where(episode: e).sum(:gold)) + } + Log.clear + MappackStory.find_each.with_index{ |s, i| + dbg("Setting gold count for story #{i + 1} / #{story_count}...", progress: true) + s.update(gold: MappackEpisode.where(story: s).sum(:gold)) + } + Log.clear + succ("Filled gold fields.", event: event) +rescue => e + lex(e, "Error performing gold check.", event: event) +end + +def send_log_config(event) + flags = parse_flags(event) + event << "Enabled logging modes: #{Log.modes.join(', ')}." if flags.empty? + flags.each{ |f, v| + str = case f + when :l + Log.level((v || 'normal').to_sym) + when :f + Log.change_fancy + when :s + Log.change_socket + when :m + Log.change_modes(v.to_s.split.map(&:to_sym)) + when :M + Log.set_modes(v.to_s.split.map(&:to_sym)) + else + '' + end + event << str if !str.empty? + } +rescue => e + lex(e, "Error changing the log config.", event: event) +end + +# Print outte and overall memory usage +def send_meminfo(event) + if !$linux + event << "Sorry, this function requires a Linux system" + return + end + + mem = getmem + total = meminfo['MemTotal'] + available = meminfo['MemAvailable'] + used = total - available + + str = "system: #{"%5d MB" % available} of #{"%5d MB" % total} (#{"%5.2f%%" % [100 * available / total]}) available\n" + str << "outte: #{"%5d MB" % mem} of #{"%5d MB" % used} (#{"%5.2f%%" % [100 * mem / used]}) used" + send_message( + event, + content: "Memory usage:\n#{format_block(str)}", + components: refresh_button('send_meminfo') + ) +rescue => e + lex(e, "Error getting memory info.", event: event) +end + +# Test RSS news embed notices +def test_rss(event, page: nil) + flags = parse_flags(event) + name = flags[:feed] || 'steam' + feed = Feed.get(name) + perror("Error fetching feed news") unless feed + if flags.key?(:list) + send_message(event, content: feed.list) + return + end + pag = get_paging(event, page, feed.size) + index = (flags[:item] || pag[:page] - 1).to_i + item = feed[index] + perror("Feed `#{name}` has no item #{index}") unless item + embed = feed.format(item) + view = interaction_add_button_navigation(nil, pag[:page], pag[:pages], func: 'test_rss') + send_message(event, components: view, embeds: [embed]) +end + +# Test Steam patch embeds +def test_steam_info(event) + app = SteamApp.find_by(id: APP_ID) + perror("N++ Steam app isn't initialized, run `!steam_seed`") if !app || !app.name + version = SteamBranchVersion.last + depot, manifest = version.build.mapping.first + embeds = [ + version.branch.embed_new, depot.embed_new, version.embed_new, + manifest.embed_new, version.branch.embed_deleted, depot.embed_deleted + ] + send_message(event, embeds: embeds) +end + +# TODO: Seed other stuff in here (stats, achievements, etc), and send summary to Discord +def seed_steam(event) + token = $steam_tokens[DATA_STEAM_ID]&.[](:token) + perror("No refresh token found") if !token + app = SteamApp.find_or_create_by(id: APP_ID) + changes = app.fetch_info(token: token) + event << "Fetched info from Steam, found #{changes} changes" + manifests = SteamManifest.where(count: nil).where.not(gid: nil).to_a + log("Found #{manifests.size} manifests that need to be fetched") + manifests.each{ |m| m.fetch(token: token) } + event << "Fetched #{manifests.count} public manifests" +end + +# Restart outte's process +def send_restart(event) + flags = parse_flags(event) + force = flags.key?(:force) + restart("Manual#{force ? ' (forced)' : ''}", force: force) +rescue => e + lex(e, "Error restarting outte.", event: event) +end + +# Shut down outte's process +def send_shutdown(event, force = false) + flags = parse_flags(event) + force ||= flags.key?(:force) + alert("#{force ? 'Killing' : 'Shutting down'} outte.", discord: true) + shutdown(trap: false, force: force) + exit +rescue => e + lex(e, "Error shutting down outte.", event: event) +end + +# Compare Ruby and C SHA1 hashes for a specific level or score +def send_hash(event) + flags = parse_flags(event) + + # Parse highscoreable + h = parse_highscoreable(event, mappack: true) + perror("Map no found.") if h.nil? + map_data = h.map.dump_level(hash: true) + perror("Map data for #{h.format_name} is null.") if map_data.nil? + + # Parse player, if provided + if flags[:p] + player = parse_player(event, false, true, flag: :p) + perror("Player #{flags[:p]} not found.") if !player + score = h.leaderboard.find{ |s| s['name'] == player.name } + perror("No score by #{player.name} in #{h.name}.") if !score + eq = MappackScore.find(score['id']).compare_hashes rescue nil + event << "The hashes are #{eq ? 'equal' : 'different'}." + return + end + + # Parse score ID, if provided + if flags[:id] + score = MappackScore.find(flags[:id]) rescue nil + perror("Mappack score with ID #{flags[:id]} not found.") if !score + eq = score.compare_hashes + event << "The hashes are #{eq ? 'equal' : 'different'}." + return + end + + # Compare hashes for all scores, or only for the map data + if flags.key?(:all) + eq = h.scores.map{ |s| s.compare_hashes }.count(false) == 0 + else + eq = h.compare_hashes + end + + event << "The hashes are #{eq ? 'equal' : 'different'}." +rescue => e + lex(e, "Error comparing hashes.", event: event) +end + +# Compare Ruby and C SHA1 hashes for all mappack levels and return list of differences +def send_hashes(event) + levels = MappackLevel.where('mappack_id > 0') + count = levels.count + res = levels.each_with_index.select{ |l, i| + dbg("Hashing level #{i} / #{count}...", newline: false, pad: true) + !l.compare_hashes + }.map{ |map, i| map.name } + event << "There are #{res.size} levels with differing hashes:" + res.size <= 20 ? event << format_block(res.join("\n")) : send_file(event, res.join("\n")) +rescue => e + lex(e, "Error getting hash discrepancies.", event: event) +end + +def send_nprofile_gen(event) + flags = parse_flags(event) + perror("You need to provide a player.") if !flags.key?(:p) + perror("You need to provide a mappack.") if !flags.key?(:m) + player = parse_player(event, false, true, flag: :p) + perror("Player not found.") if player.nil? + mappack = parse_mappack(flags[:m], explicit: true, vanilla: false) + perror("You need to provide a mappack.") if !mappack + perror("Can't generate an nprofile for Metanet.") if mappack.id == 0 + mid = mappack.id + nprofile = unzip(File.binread(File.join(DIR_UTILS, 'nprofile.zip')))['nprofile'] + size = nprofile.size + # TODO: Add gold to episodes + MappackScore.where(player: player, mappack: mappack) + .order(highscoreable_id: :asc, gold: :asc) + .pluck(:highscoreable_id, :highscoreable_type, :score_hs, :gold) + .each{ |id, type, score, gold| + type = type.remove('Mappack') + case type + when 'Level' + offset = 0x80D320 + when 'Episode' + offset = 0x8F7920 + when 'Story' + offset = 0x926720 + end + id = id - TYPES[type][:slots] * mid + o = offset + 48 * id + nprofile[o + 20] = "\x02".b + nprofile[o + 48 + 20] = "\x01".b + old_gold = nprofile[o + 24...o + 28].unpack('l<')[0] + nprofile[o + 24...o + 28] = [gold].pack('l<') if gold > old_gold + score = (1000.0 * score.to_i / 60.0).round + old_score = nprofile[o + 36...o + 40].unpack('l<')[0] + nprofile[o + 36...o + 40] = [score].pack('l<') if score > old_score + } + perror("Size mismatch after nprofile patch") if nprofile.size != size + File.binwrite( + "#{sanitize_filename(player.name)}_nprofile.zip", + zip({ 'nprofile' => nprofile }) + ) + event << "#{mappack.code.upcase} nprofile for #{player.name} was generated" +rescue => e + lex(e, "Error generating nprofile.", event: event) +end + +# Remove duplicate and obsolete users, fill in Discord ID field +def sanitize_users(event) + # Remove obsolete users, i.e., those whose name doesn't exist in the server + # These users are now unreachable, as a consequence of outte using usernames + # rather than Discord IDs for a long time (now changed) + users = User.all.map{ |u| [u, find_users(name: u.name)] } + users.select{ |u| u.last.empty? }.each{ |u| u.first.delete } + users.reject!{ |u| u.last.empty? } + + # Fill in Discord ID field for all the remaining users, unless there are + # multiple matches, in which case we will do it manually later + users.each{ |u| + u.first.update(discord_id: u.last.first.id, name: u.last.first.name) if u.last.size == 1 + } + + # Remove duplicate users, while keeping the relevant info + names = User.group(:name).having('count(name) > 1').pluck(:name) + names.each{ |n| + player_id = User.where(name: n).where.not(player_id: nil).order(id: :desc).first.player_id rescue nil + palette = User.where(name: n).where.not(palette: nil).order(id: :desc).first.palette rescue nil + + copies = User.where(name: n).order(id: :desc).to_a + copies[0].update(player_id: player_id, palette: palette) + copies[1..-1].each(&:delete) + } + + event << "Sanitized users" +rescue => e + lex(e, "Error sanitizing users.", event: event) +end + +# Remove bad hashes missing their corresponding mappack score +def sanitize_hashes(event) + count = BadHash.sanitize + event << "Sanitized bad hashes, removed #{count} orphans." +rescue => e + lex(e, "Error sanitizing bad hashes.", event: event) +end + +# Remove mappack demos missing their corresponding score +def sanitize_demos(event) + count = MappackDemo.sanitize + event << "Sanitized mappack demos, removed #{count} orphans." +rescue => e + lex(e, "Error sanitizing mappack demos.", event: event) +end + +# Update all SHA1 hashes for every mappack highscoreable (all versions too) +def seed_hashes(event) + flags = parse_flags(event) + mappack = parse_mappack(flags[:mappack], explicit: true, vanilla: false) + types = parse_type(flags[:type].to_s, multiple: true) + types = [Level, Episode, Story] if types.empty? + send_message(event, content: 'Seeding mappack SHA1 hashes.') + count = MappackHash.seed(mappack: mappack, types: types) + event << "Seeded mappack hashes, updated #{count} hashes." +rescue => e + lex(e, "Error seeding mappack hashes.", event: event) +end + +# Manually update the Discord ID of a user by name +def set_user_id(event) + flags = parse_flags(event) + perror("You must provide a username.") if !flags[:name] + name = flags[:name].split('#').first + user = User.where(name: name) + perror("No user found by the name #{verbatim(name)}.") if user.empty? + perror("Multiple users found by the name #{verbatim(name)}.") if user.size > 1 + user = user.first + discord_user = parse_discord_user("for #{flags[:name]}") + old_id = user.discord_id + new_id = discord_user.id + perror("#{user.name}'s Discord ID is already #{old_id}.") if old_id == new_id + user.update(discord_id: new_id) + if old_id + event << "Changed #{user.name}'s Discord ID from #{old_id} to #{new_id}." + else + event << "Set #{user.name}'s Discord ID to #{new_id}." + end +rescue => e + lex(e, "Error setting user ID.", event: event) +end + +# Manually set the ID of the replay that will be sent by CLE to the botmaster +# in the next get_replay queries +def set_replay_id(event) + msg = remove_command(parse_message(event)) + if msg =~ /\d+/ + id = msg[/\d+/].to_i + score = MappackScore.find_by(id: id) + perror("Mappack score with ID #{id} not found.") if !score + GlobalProperty.find_by(key: 'replay_id').update(value: id) + event << "Set manual replay ID to #{msg[/\d+/]} (#{score.player.name} - #{score.highscoreable.name})." + else + GlobalProperty.find_by(key: 'replay_id').update(value: nil) + event << "Unset manual replay ID." + end +rescue => e + lex(e, "Error changing manual replay ID.", event: event) +end + +def submit_score(event) + flags = parse_flags(event) + + if !flags.key?(:all) + # Submit a score to an individual highscoreable + # TODO: Extend with flags for score/player/... + perror("You must provide a downloadable.") if !flags[:h] + if is_num(flags[:h]) + h = Userlevel.find_by(id: flags[:h].to_i) + perror("No userlevel with ID #{flags[:h]}.") if !h + else + h = parse_highscoreable(event) + end + res = h.submit_zero_score(log: true) + if res + perror("Failed to submit zero score to #{verbatim(h.name)} (incorrect hash?)") if res['rank'].to_i < 0 + if res['better'] == 0 + str = "Already had a better score in #{verbatim(h.name)}: " + else + str = "Submitted zero score to #{verbatim(h.name)}: " + end + score = '%.3f' % round_score(res['score'].to_i / 1000.0) + str << "replay ID #{res['replay_id']}, rank: #{res['rank']}, score: #{score}." + succ(str, event: event) + end + else + msgs = [nil] + if !flags.key?(:userlevels) + [Level, Episode, Story].each{ |type| + Downloadable.submit_zero_scores(type.where(completions: nil), event: event, msgs: msgs) + } + else + Downloadable.submit_zero_scores( + Userlevel.where("submitted = 0 AND completions >= #{MIN_0_SCORES}"), + event: event, + msgs: msgs + ) + end + TmpMsg.update(event, "Finished submitting all remaining zero scores.") + end +rescue => e + lex(e, 'Failed to submit score.', event: event) +end + +# Update how many completions a Metanet highscoreable / userlevel (or all) has +def update_completions(event) + flags = parse_flags(event) + global = flags.key?(:global) + mine = flags.key?(:mine) + global = global ? true : mine ? false : nil + + if !flags.key?(:all) + # Update completion count for individual highscoreable + perror("You must provide a downloadable.") if !flags[:h] + if is_num(flags[:h]) + h = Userlevel.find_by(id: flags[:h].to_i) + perror("No userlevel with ID #{flags[:h]}.") if !h + else + h = parse_highscoreable(event) + perror("This highscoreable is not downloadable.") if !h.is_a?(Downloadable) + end + count_old = h.completions.to_i + count_new = h.update_completions(log: true, stop: true, global: global) + name = h.is_userlevel? ? "userlevel #{h.id}" : h.name + if count_new + count_new = count_new.to_i + diff = count_new - count_old + event << "Updated #{name} completions from #{count_old} to #{count_new} (+#{diff})." + else + event << "Failed to fetch completions for #{name}." + end + else + # Update completion count for all highscoreables + # TODO: Make Discord logging optional, for when we manage to automate Steam + # authentification, leading to automating this function in the background + delta = 0 + retries = flags[:retries].to_i + msgs = [nil] + if !flags.key?(:userlevels) + type = parse_type(flags[:type].to_s) + tabs = parse_tabs(flags[:tabs].to_s) + (type ? [type] : [Level, Episode, Story]).each{ |t| + delta += Downloadable.update_completions( + !tabs.empty? ? t.where(tab: tabs) : t, + event: event, msgs: msgs, retries: retries, global: global + ) + } + else + delta += Downloadable.update_completions( + Userlevel.where("submitted = 1 OR completions >= #{MIN_0_SCORES}"), + event: event, msgs: msgs, retries: retries, global: global + ) + end + TmpMsg.update(event, "Finished updating completions, gained #{delta} ones.") + end +rescue => e + lex(e, 'Failed to update completions.', event: event) +end + +def userlevel_completions(event) + UserlevelScore.seed_completions + succ("Seeded userlevel completions.", event: event) +rescue => e + lex(e, 'Failed to seed userlevel completions.', event: event) +end + +# Manually delete a score from the database +def send_delete_score(event) + msg = remove_command(parse_message(event)) + flags = parse_flags(msg) + + # Fetch a score, either by ID, or by (player, highscoreable, board) + if flags.key?(:p) || flags.key?(:h) || flags.key?(:b) + perror("You need to specify a player.") if !flags[:p] + perror("You need to specify a highscoreable.") if !flags[:h] + perror("You need to specify a board.") if !flags[:b] + p = parse_player(event, false, true, true, flag: :p) + h = parse_highscoreable(event, mappack: true, map: true) + perror("Metanet scores cannot be deleted.") if h.mappack.code.downcase == 'met' + board = parse_board(flags[:b]) + perror("The board needs to be hs/sr.") if !['hs', 'sr'].include?(board) + score = MappackScore.where(player: p, highscoreable: h) + .where("rank_#{board} IS NOT NULL") + .first + perror("#{p.name} doesn't have a score in #{h.name}.") if !score + id = score.id + else + id = msg[/\d+/] + perror("You need to specify a score ID") if !id + score = MappackScore.find_by(id: id.to_i) + perror("Score with ID #{id} not found.") if !score + h = score.highscoreable + p = score.player + end + + # Send confirmation message with Yes/No buttons + # Response will be handled in delete_score@interactions, and buttons will be removed + send_message( + event, + content: "Delete %s's %.3f score in %s (ID %d)?" % [p.name, round_score(score.score_hs / 60.0), h.name, id], + components: interaction_add_confirmation_buttons + ) +rescue => e + lex(e, 'Failed to delete mappack score.', event: event) +end + +# Deletes an outte message if it was sent by the reacting user recently +# The botmaster can delete any message any time +def delete_message(event) + msg = event.message + return if Time.now - msg.timestamp > DELETE_TIMELIMIT && !is_botmaster?(event) + return if !Message.find_by(id: msg.id, user_id: event.user.id) && !is_botmaster?(event) + lin( + "[#{event.user.name}] deleted message #{msg.id} (#{msg.content[0, 24]}) in [#{msg.channel.name}] by reacting with #{event.emoji.to_s}") + msg.delete +rescue => e + lex(e, 'Failed to delete outte message.', event: event) +end + +def report_message(event) + event.message.content =~ /<(https:\/\/www.twitch.tv\/(.+?))>$/i ? Twitch::report_stream(event) : delete_message(event) +end + +# Tests ntrace on many runs (e.g. all Metanet 0ths) +def test_ntrace(event) + # Parse params + flags = parse_flags(event) + tabs = parse_tabs(flags[:tabs].to_s) + if flags.key?(:mappack) + if flags[:mappack] + klass = MappackLevel.where(mappack: parse_mappack(flags[:mappack])) + else + klass = MappackLevel.where('mappack_id > 0') + end + else + klass = Level.all + end + klass = klass.where(mode: 0) if flags.key?(:solo) + klass = klass.where(tab: tabs) if !tabs.empty? + klass = klass.select{ |l| l.tiles.flatten.none?{ |t| t > 33 } } if flags.key?(:glitchless) + klass = klass.select{ |l| l.tiles.flatten.any?{ |t| t > 33 } } if flags.key?(:glitchful) + count = klass.count + + # Execute test + results = klass.each_with_index.map{ |l, i| + dbg("Testing ntrace on level #{i + 1} / #{count}...", progress: true) + [l.name, l.map.test_ntrace] + }.to_h + Log.clear + log("Finished testing ntrace") + good = results.select{ |k, v| v == :good }.to_h + bad = results.select{ |k, v| v == :bad }.to_h + error = results.select{ |k, v| v == :error }.to_h + other = results.select{ |k, v| v == :other }.to_h + + # Format results + event << "Results from ntrace test:" + block = "" + block << "Mappack: #{mappack.code.upcase rescue 'MET'}\n" + block << "Tabs: #{tabs.empty? ? 'All' : format_tabs(tabs)}\n" + block << "-------------\n" + block << "Good: #{'%4d' % good.size}\n" + block << "Bad: #{'%4d' % bad.size}\n" + block << "Error: #{'%4d' % error.size}\n" + block << "Other: #{'%4d' % other.size}\n" + block << "-------------\n" + block << "Total: #{'%4d' % results.size}" + event << format_block(block) + file = "" + file << "GOOD: #{good.size}\n\n" if flags.key?(:good) + file << good.keys.join("\n") + "\n\n" if flags.key?(:good) + file << "BAD: #{bad.size}\n\n" + file << bad.keys.join("\n") + "\n\n" + file << "ERROR: #{error.size}\n\n" + file << error.keys.join("\n") + "\n\n" + file << "OTHER: #{other.size}\n\n" + file << other.keys.join("\n") + send_file(event, file, "ntrace-test.txt", false) +rescue => e + lex(e, 'Failed to test ntrace') +end + +# Update a userlevel author's name and add a new A.K.A. +def rename_author(event) + flags = parse_flags(event) + id = flags[:id] + name = flags[:name] + perror("Usage: !rename_author -id ID -name NAME") unless !!id && !!name + author = UserlevelAuthor.find_by(id: id.to_i) + perror("No userlevel author with ID #{id}.") unless author + author.rename(name) + event << "Renamed author #{id} to #{verbatim(name)}." +rescue => e + lex(e, 'Failed to rename author.') +end + +# Fetch and print current relevant MySQL variables and status +def send_sql_status(event) + update_sql_status + + # Connections established to MySQL database + str = "Connections: " + str << "#{$sql_status['Threads_connected']} open, " + str << "#{$sql_status['Max_used_connections']} highest, " + str << "#{$sql_vars['max_connections']} max, " + str << "#{$sql_status['Connections']} total\n" + + # MySQL threads alive + str << "Threads: " + str << ['connected', 'running', 'cached', 'created'].map{ |t| + "#{$sql_status["Threads_#{t}"]} #{t}" + }.join(", ") + "\n" + + # Rails connection pool info + str << "Rails pool: " + str << ActiveRecord::Base.connection_pool.stat.map{ |p| + next nil if p[1].is_a?(Float) + p.map(&:to_s).join(' ') + }.compact.join(', ') + + # Send + send_message( + event, + content: "Database status #{format_time}:\n" + format_block(str), + components: refresh_button('send_sql_status') + ) +rescue => e + lex(e, 'Failed to send database status.', event: event) +end + +# Print detailed list of all current open connections to the MySQL server +def send_sql_list(event) + update_sql_status + if !$sql_conns.size == 0 + event << "There are no open MySQL connections." + return + end + flags = parse_flags(event) + last = flags.key?(:full) ? -1 : -3 + rows = [] + rows << $sql_conns.first.keys[0..last] + rows << :sep + $sql_conns.each{ |row| rows << row.values[0..last] } + send_message( + event, + content: format_block(make_table(rows)) + "Total: #{$sql_conns.size}", + components: refresh_button('send_sql_list') + ) +rescue => e + lex(e, 'Failed to send database thread list.', event: event) +end + +# Print information about all the running background tasks +def send_tasks(event) + rows = [] + rows << ["Name", "State", "Runs", "Last run", "Next run"] + rows << :sep + totals = {} + Scheduler.list.sort_by{ |job| + [ + Job.states[job.state][:order], + job.eta ? 0 : 1, + job.eta, + job.time ? 0 : 1, + job.time, + -job.count, + job.task.name + ] + }.each{ |job| + rows << [ + job.task.name, + Job.states[job.state][:desc].capitalize, + job.count, + (job.time.strftime('%b %d %R') rescue ''), + format_timespan(job.eta) + ] + totals.key?(job.state) ? totals[job.state] += 1 : totals[job.state] = 1 + } + send_message( + event, + content: "Tasks scheduled:\n" + format_block(make_table(rows)) + "Total: #{totals.map{ |k, v| "#{v} #{k}" }.join(', ')}", + components: refresh_button('send_tasks') + ) +rescue => e + lex(e, 'Failed to send background task list.', event: event) +end + +def send_debug(event) + flags = parse_flags(event) + if flags.key?(:byebug) then byebug else binding.pry end +rescue => e + lex(e, 'Failed to start debugger.', event: event) +end + +def send_status(event) + status = GlobalProperty.status + str = "Uptime: #{format_timespan(Time.now - $boot_time)} (boot #{$boot_time.strftime('%F %T')})\n" + str << "Commands: #{status['commands']} normal, #{status['special_commands']} special, #{status['main_commands']} on main thread\n" + str << "Received: #{status['pings']} mentions, #{status['dms']} DMs, #{status['interactions']} interactions\n" + str << "Sent: #{status['messages']} messages, #{status['edits']} edits\n" + str << "Logged: #{status['logs']} lines, #{status['errors']} errors, #{status['warnings']} warnings, #{status['exceptions']} exceptions\n" + str << "Network: #{status['http_requests']} requests, #{status['http_errors']} errors, #{status['http_forwards']} forwards\n" + str << "CLE: #{status['http_scores']} leaderboards, #{status['http_replay']} replays, #{status['http_submit']} submissions, #{status['http_login']} logins, #{status['http_levels']} userlevel queries\n" + str << "API: #{status['api']} requests (#{status['api_cached']} cached, #{status['api_error']} errors), #{status['api_home']} home, #{status['api_scores']} scores, #{status['api_run']} run, #{status['api_git']} git" + send_message( + event, + content: "Status #{format_time}:\n" + format_block(str), + components: refresh_button('send_status') + ) +rescue => e + lex(e, 'Failed to send outte status.', event: event) +end + +# TODO: only add formatting to necessary lines. we can do this by extracting the +# part that filters the lines out of the pager to a different function, and using +# it here +def send_logs(event, page: nil) + lines = File.readlines(PATH_LOG_FILE).reverse.map{ |l| + next nil if l.length <= 34 + type = l[/(? e + lex(e, "Failed to send outte logs.", event: event) +end + +# Send the highscoring report +def test_report(event) + msg = parse_message(event) + flags = parse_flags(event) + date = parse_date(msg) + if !flags.key?(:userlevel) + send_message(event, content: generate_report(date) + "\n" + generate_summary(date)) + else + send_userlevel_report(event.channel, histories: false, date: date) + end +end + +# Computes the interpolated scores using NSim and seeds them in the archives table +def seed_fractional_scores(event, log = true) + # Fetch scores whose fraction needs to be computed + list1 = Archive.where(lost: false, simulated: false, highscoreable_type: Level) + list2 = MappackScore.joins('INNER JOIN `mappack_levels` ON `mappack_levels`.`id` = `highscoreable_id`') + .where(simulated: false, highscoreable_type: MappackLevel, mappack_levels: { mode: MODE_SOLO }) + count1 = list1.count + count2 = list2.count + count = count1 + count2 + counts = { good: 0, bad: 0, lost: 0 } + batch_size = 100 + start_time = Time.now + + # Progress logger + logger = -> { + time = format_timespan(Time.now - start_time) + print( + "Seeded "\ + "#{ANSI.format(counts.values.sum, fg: ANSI::BLUE)} / "\ + "#{ANSI.format(count, fg: ANSI::BLUE)} fractional scores ("\ + "#{ANSI.format(counts[:good], fg: ANSI::GREEN)} good, "\ + "#{ANSI.format(counts[:bad], fg: ANSI::RED)} bad, "\ + "#{ANSI.format(counts[:lost], fg: ANSI::RED)} lost) ["\ + "#{ANSI.format(time, fg: ANSI::YELLOW)}]\r" + ) + } + + # Seed vanilla fractions + list1.find_each(batch_size: batch_size).with_index{ |score, i| + counts[score.seed_fraction] += 1 + logger.call if log + } unless count1 == 0 + + # Seed mappack fractions + list2.find_each(batch_size: batch_size).with_index{ |score, i| + counts[score.seed_fraction] += 1 + logger.call if log + } unless count2 == 0 + + puts if log + succ("Finished seeding fractional scores") if log +end + +def thread_continue(event) + flags = parse_flags(event) + name = flags[:name] + perror("You need to provide the togglable thread name via the `name` parameter.") if !name + th = toggle_thread_get(name) + perror("Togglable thread with name `#{name}` does not exist") unless th + perror("Togglable thread with name `#{name}` isn't sleeping") unless th.status == 'sleep' + th.run + event << "Togglable thread with name `#{name}` awakened" +end + +def test_lotd(event) + flags = parse_flags(event) + ctp = flags.key?(:ctp) + start_level_of_the_day(event.channel, ctp: ctp, save: false, test: true) if flags.key?(:full) + send_channel_next(event.channel, Level, ctp: ctp, save: false) if flags.key?(:level) + send_channel_next(event.channel, Episode, ctp: ctp, save: false) if flags.key?(:episode) + send_channel_next(event.channel, Story, ctp: ctp, save: false) if flags.key?(:story) + event << lotd_reminders(ctp: ctp, episode: true, story: true) if flags.key?(:reminder) +end + +# Sort levels by object counts and other stats, and return screenies +def send_object_counts(event) + flags = parse_flags(event) + levels = [] + texts = [] + + # Fetch object counts (costly, so we log progress) + counts = MappackLevel.where(mappack_id: 0, mode: 0).all.map.with_index{ |l, i| + dbg(i, progress: true) + [l, l.object_counts] + }.to_h + + # Find the 5 levels with most and least objects (total and types) + totals = counts.map{ |l, c| [l, c.sum] }.sort_by{ |l, c| [c, l.id] } + types = counts.map{ |l, c| [l, c.reject(&:zero?).size] }.sort_by{ |l, c| [c, l.id] } + (totals[-5, 5].reverse + totals[0, 5]).each{ |l, c| + levels << l + texts << "#{c} objects\n#{l.name.remove('MET-')}" + } + (types[-5, 5].reverse + types[0, 5]).each{ |l, c| + levels << l + texts << "#{c} object types\n#{l.name.remove('MET-')}" + } + + # Find the 5 levels with most objects of each type + Map::OBJECTS.each{ |id, hash| + next if [Map::ID_NINJA, Map::ID_EXIT_SWITCH, Map::ID_DOOR_LOCKED_SWITCH, Map::ID_DOOR_TRAP_SWITCH].include?(id) + list = counts.select{ |l, c| c[id] > 0 }.sort_by{ |l, c| -c[id] }.take(5) + levels.push(*list.map(&:first)) + texts.push(*list.map{ |l, c| "%d %s\n%s" % [c[id], hash[:name].pluralize, l.name.remove('MET-')] }) + }.compact + + # Generate screenshot (costly) and configure ImageMagick + dbg("\nGenerating screenshot...") + png = Magick::Image.from_blob(Map.screenshot(h: levels)).first + draw = Magick::Draw.new{ |d| + d.font = File.join(DIR_FONTS, 'sys', 'Sys.otf') + d.pointsize = 24 + d.fill = 'black' + d.font_weight = Magick::BoldWeight + d.gravity = Magick::NorthWestGravity + } + + # Overlay texts + texts.each_with_index{ |text, index| + row = index / 5 + col = index % 5 + w = png.columns / 5 + h = png.rows * 5 / texts.size + x = col * w + y = row * h + draw.annotate(png, w, h, x + 8, y + 2, text) + } + + send_message(botmaster.pm, files: [tmp_file(png.to_blob, 'res.png', binary: true)]) +end + +# Add a certain gold count correction that will be applied to scores of highscoreables +# that were edited at some point and have incorrect scores as a consequence. +def add_score_correction(event) + flags = parse_flags(event) + h = parse_highscoreable(event, mappack: true) + perror("You need to specify a gold count with `-gold`") unless flags[:gold] + ScoreCorrection.find_or_create_by(highscoreable: h).update(gold: flags[:gold].to_i) + event << "Added %+d gold correction to %s" % [flags[:gold], h.format_name] +end + +# Tranfer mappack scores from one player to another. Useful when scores were +# wrongly submitted with another account, e.g. when authentication failed. +def transfer_scores(event) + flags = parse_flags(event) + perror("Specify the players with `-from` and `-to`") unless flags[:from] && flags[:to] + perror("Specify the score ID range with `-start` and `-end`") unless flags[:start] && flags[:end] + perror("Invalid ID range") if flags[:start].to_i > flags[:end].to_i + from_player = parse_player_explicit(flags[:from]) + to_player = parse_player_explicit(flags[:to]) + scores = MappackScore.where(player: from_player, id: (flags[:start]..flags[:end])) + score_count = scores.count + highscoreables = scores.map(&:highscoreable).uniq + highscoreable_count = highscoreables.count + scores.update_all(player_id: to_player.id, metanet_id: to_player.metanet_id) + highscoreables.each_with_index{ |h, i| + dbg("Patching score #{i + 1} / #{highscoreables.size}...", progress: true) + frac = h.mappack.fractional && h.is_level? + ['hs', 'sr'].each{ |board| + from_player.update_rank(h, board, frac: frac) + to_player.update_rank(h, board, frac: frac) + } + } + event << "Patched #{score_count} scores in #{highscoreable_count} boards." +end + +# Special commands can only be executed by the botmaster, and are intended to +# manage the bot on the fly without having to restart it, or to print sensitive +# information. +# +# The syntax is always the same: !commandname +# It may optionally be followed by flags, which follow classic UNIX conventions +# The syntax is more strict since that allows for more precision, and flexibility +# is no longer required as it's not aimed at the general user base. +# +# Example: +# !react -c A -m B -r C +# Will react to the message with id B in channel with name A with emoji C +def respond_special(event) + assert_permissions(event) + msg = parse_message(event).strip + cmd = msg[/^!(\w+)/i, 1] + return if cmd.nil? + cmd.downcase! + action_inc('special_commands') + + return send_admin_panel(event) if cmd == 'admin' + return send_dday_stats(event) if cmd == 'dday_stats' + return add_score_correction(event) if cmd == 'correct_score' + return send_debug(event) if cmd == 'debug' + return send_delete_score(event) if cmd == 'delete_score' + return fill_gold_counts(event) if cmd == 'fill_gold' + return send_gold_check(event) if cmd == 'gold_check' + return send_hash(event) if cmd == 'hash' + return send_hashes(event) if cmd == 'hashes' + return send_highscore_plot(event) if cmd == 'highscores_plot' + return send_shutdown(event, true) if cmd == 'kill' + return send_logs(event) if cmd == 'log' + return send_log_config(event) if cmd == 'logconf' + return test_lotd(event) if cmd == 'lotd' + return send_mappack_authors(event) if cmd == 'mappack_authors' + return send_mappack_challenges(event) if cmd == 'mappack_challenges' + return send_mappack_completions(event) if cmd == 'mappack_completions' + return send_mappack_digest(event) if cmd == 'mappack_digest' + return send_mappack_info(event) if cmd == 'mappack_info' + return send_mappack_patch(event) if cmd == 'mappack_patch' + return send_mappack_ranks(event) if cmd == 'mappack_ranks' + return send_mappack_scores(event) if cmd == 'mappack_scores' + return send_mappack_seed(event) if cmd == 'mappack_seed' + return send_mappack_update(event) if cmd == 'mappack_update' + return send_mappack_userlevels(event) if cmd == 'mappack_userlevels' + return send_meminfo(event) if cmd == 'meminfo' + return send_nprofile_gen(event) if cmd == 'nprofile_gen' + return send_object_counts(event) if cmd == 'object_counts' + return send_reaction(event) if cmd == 'react' + return rename_author(event) if cmd == 'rename_author' + return test_report(event) if cmd == 'report' + return send_restart(event) if cmd == 'restart' + return test_rss(event) if cmd == 'rss' + return sanitize_archives(event) if cmd == 'sanitize_archives' + return sanitize_demos(event) if cmd == 'sanitize_demos' + return sanitize_hashes(event) if cmd == 'sanitize_hashes' + return sanitize_userlevels(event) if cmd == 'sanitize_userlevels' + return sanitize_users(event) if cmd == 'sanitize_users' + return scan_boards(event) if cmd == 'scan_boards' + return seed_fractional_scores(event) if cmd == 'seed_fractions' + return seed_hashes(event) if cmd == 'seed_hashes' + return set_user_id(event) if cmd == 'set_user_id' + return set_replay_id(event) if cmd == 'set_replay_id' + return send_shutdown(event) if cmd == 'shutdown' + return send_sql_list(event) if cmd == 'sql_list' + return send_sql_status(event) if cmd == 'sql_status' + return send_status(event) if cmd == 'status' + return test_steam_info(event) if cmd == 'steam_embeds' + return seed_steam(event) if cmd == 'steam_seed' + return submit_score(event) if cmd == 'submit' + return transfer_scores(event) if cmd == 'transfer_scores' + return send_tasks(event) if cmd == 'tasks' + return send_test(event) if cmd == 'test' + return send_color_test(event) if cmd == 'test_color' + return test_ntrace(event) if cmd == 'test_ntrace' + return thread_continue(event) if cmd == 'thread_continue' + return send_unreaction(event) if cmd == 'unreact' + return update_completions(event) if cmd == 'update_completions' + return userlevel_completions(event) if cmd == 'userlevel_completions' + return send_ul_csv(event) if cmd == 'userlevel_csv' + return send_ul_plot(event) if cmd == 'userlevel_plot' + + action_dec('special_commands') + event << "Unsupported special command." +end + +# Done loading file +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) diff --git a/src/api.rb b/src/api.rb new file mode 100644 index 00000000..86e7fad0 --- /dev/null +++ b/src/api.rb @@ -0,0 +1,3271 @@ +# This file contains the classes used to communicate with the various APIs we +# need to interact with: +# - Twitch.com: Fetching N-related streams +# - Speedrun.com: Leaderboards, notifications for new/verified runs, etc. +# - Steam: Auth tickets, apps, branches, depots, manifests, builds, etc. +# - RSS: Track various feeds for N++ updates. +# - N++: Our custom game server (NPPServer) for mappacks +# - Custom API: Our custom API (APIServer) to request stats on command +# We also have a general socket module (Sock) which is the base for both servers + +$load_time = Time.now + +require 'html_to_markdown' +require 'json' +require 'net/http' +require 'nokogiri' +require 'octicons' +require 'puma' +require 'rack' +require 'rss' +require 'set' +require 'socket' + +module Twitch extend self + + GAME_IDS = { +# 'N' => 12273, # Commented because it's usually non-N related :( + 'N+' => 18983, + 'Nv2' => 105456, + 'N++' => 369385, + 'N+∞x2' => 1217665734 +# 'GTASA' => 6521 # This is for testing purposes, since often there are no N streams live + } + + def get_twitch_token + acquire_connection + $twitch_token = GlobalProperty.find_by(key: 'twitch_token').value + release_connection + update_twitch_token if !$twitch_token + $twitch_token + end + + def set_twitch_token(token) + acquire_connection + GlobalProperty.find_by(key: 'twitch_token').update(value: token) + release_connection + end + + def length(s) + (Time.now - DateTime.parse(s['started_at']).to_time).to_i / 60.0 + end + + def table_header + "#{"Player".ljust(15, " ")} #{"Title".ljust(35, " ")} #{"Time".ljust(12, " ")} #{"Views".ljust(4, " ")}\n#{"-" * 70}" + end + + def format_stream(s) + name = to_ascii(s['user_name']).strip[0...15].ljust(15, ' ') + title = to_ascii(s['title']).strip[0...35].ljust(35, ' ') + time = "#{length(s).to_i} mins ago".rjust(12, ' ') + views = s['viewer_count'].to_s.rjust(5, ' ') + "#{name} #{title} #{time} #{views}" + end + + def update_twitch_token + res = Net::HTTP.post_form( + URI.parse("https://id.twitch.tv/oauth2/token"), + { + client_id: $config['twitch_client'].to_s, + client_secret: $config['twitch_secret'].to_s, + grant_type: 'client_credentials' + } + ) + if res.code.to_i == 401 + err("TWITCH: Unauthorized to perform requests, please verify you have this correctly configured.") + elsif res.code.to_i != 200 + err("TWITCH: App access token request failed (code #{res.body}).") + else + $twitch_token = JSON.parse(res.body)['access_token'] + set_twitch_token($twitch_token) + end + rescue => e + lex(e, "TWITCH: App access token request method failed") + sleep(5) + retry + end + + # TODO: Add attempts to the loop, raise if fail + def get_twitch_game_id(name) + get_twitch_token if !$twitch_token + uri = URI("https://api.twitch.tv/helix/games?name=#{CGI.escape(name)}") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + loop do + res = http.get( + uri.request_uri, + { + 'Authorization' => "Bearer #{$twitch_token}", + 'Client-Id' => $config['twitch_client'].to_s + } + ) + if res.code.to_i == 401 + update_twitch_token + sleep(5) + elsif res.code.to_i != 200 + err("TWITCH: Game ID request failed.") + sleep(5) + else + return JSON.parse(res.body)['id'].to_i + end + end + rescue => e + lex(e, 'TWITCH: Game ID request method failed.') + sleep(5) + retry + end + + # TODO: Add attempts to the loops, raise if fail + # TODO: Add offset/pagination for when there are many results + def get_twitch_streams(name, offset = nil) + if !GAME_IDS.key?(name) + err("TWITCH: Supplied game not known.") + return + end + get_twitch_token if !$twitch_token + uri = URI("https://api.twitch.tv/helix/streams?first=100&game_id=#{GAME_IDS[name]}") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + res = nil + loop do + res = http.get( + uri.request_uri, + { + 'Authorization' => "Bearer #{$twitch_token}", + 'Client-Id' => $config['twitch_client'].to_s + } + ) + if res.code.to_i == 401 + update_twitch_token + sleep(5) + elsif res.code.to_i != 200 + err("TWITCH: Stream list request for #{name} failed (code #{res.code.to_i}).") + sleep(5) + else + return JSON.parse(res.body)['data'] + end + end + rescue => e + lex(e, "TWITCH: Stream list request method for #{name} failed.") + sleep(5) + retry + end + + def update_twitch_streams + # Update streams for each followed game + GAME_IDS.each{ |game, id| + new_streams = get_twitch_streams(game) + $twitch_streams[game] = [] if !$twitch_streams.key?(game) + + # Reject blacklisted streams + new_streams.reject!{ |s| TWITCH_BLACKLIST.include?(s['user_id'].to_i) } + + # Update values of already existing streams + $twitch_streams[game].each{ |stream| + new_stream = new_streams.select{ |s| s['user_id'] == stream['user_id'] }.first + if !new_stream.nil? + stream.merge!(new_stream) + stream['on'] = true + else + stream['on'] = false + end + } + + # Add new streams + new_streams.reject!{ |s| + $twitch_streams[game].map{ |ss| ss['user_id'] }.include?(s['user_id']) + } + new_streams.each{ |stream| stream['on'] = true } + $twitch_streams[game].push(*new_streams) + + # Delete obsolete streams + $twitch_streams[game].reject!{ |stream| + stream.key?('on') && !stream['on'] && stream.key?('posted') && (Time.now.to_i - stream['posted'] > TWITCH_COOLDOWN) + } + + # Reorder streams + $twitch_streams[game].sort_by!{ |s| -Time.parse(s['started_at']).to_i } + } + end + + def active_streams + $twitch_streams.map{ |game, list| + [game, list.select{ |s| s['on'] }] + }.to_h + end + + def new_streams + active_streams.map{ |game, list| + [game, list.select{ |s| !s['posted'] && Time.parse(s['started_at']).to_i > $boot_time.to_i }] + }.to_h + end + + def post_stream(stream) + return if $content_channel.nil? + game = GAME_IDS.invert[stream['game_id'].to_i] + return if !game + send_message($content_channel, content: "#{ping(TWITCH_ROLE)} #{verbatim(stream['user_name'])} started streaming **#{game}**! #{verbatim(stream['title'])} ") + return if !$twitch_streams.key?(game) + s = $twitch_streams[game].select{ |s| s['user_id'] == stream['user_id'] }.first + s['posted'] = Time.now.to_i if !s.nil? + rescue => e + lex(e, 'Failed to post new Twitch stream') + end + + # TODO: Allow for positive emoji to counteract negative emoji to prevent snipers + def report_stream(event) + msg = event.message.content + msg =~ /<(https:\/\/www.twitch.tv\/(.+?))>$/i + url, user = $1, $2 + return if !user + embed = Discordrb::Webhooks::Embed.new( + description: "🚨 Stream by [`#{user}`](<#{url}>) has been reported.", + color: SPEEDRUN_COLOR_NEW, + timestamp: Time.now, + footer: Discordrb::Webhooks::EmbedFooter.new(text: "Reported by #{event.user.name}") + ) + send_message($content_channel, embeds: [embed]) + end + + # TODO: Accumulate reports to different streams in db, after a certain amount trigger autoban + def ban_stream(event) + msg = event.message.content + msg =~ /<(https:\/\/www.twitch.tv\/(.+?))>$/i + url, user = $1, $2 + return if !user + n = 5 + embed = Discordrb::Webhooks::Embed.new( + description: "🔨 Stream by [`#{user}`](<#{url}>) has been blacklisted.", + color: SPEEDRUN_COLOR_REJ, + timestamp: Time.now, + footer: Discordrb::Webhooks::EmbedFooter.new(text: "After having #{n} streams reported.") + ) + send_message($content_channel, embeds: [embed]) + end +end + +# Handle Speedrun.com API +module Speedrun extend self + + # Basic API info + WEB_ROOT = 'https://www.speedrun.com' + API_ROOT = 'https://www.speedrun.com/api' + API_VERSION = 1 + RETRIES = 5 + + # API routes we use (max count = 200) + ROUTE_CATEGORIES = 'categories' + ROUTE_GAMES = 'games' + ROUTE_LEADERBOARDS = 'leaderboards/%s/category/%s' # game ID, category ID + ROUTE_LEADERBOARDS_LEVEL = 'leaderboards/%s/level/%s/%s' # game ID, level ID, category ID + ROUTE_RUNS = 'runs' + ROUTE_SERIES = 'series' + ROUTE_SERIES_GAMES = 'series/%s/games' # series ID + + # Map common platform IDs to names. We still fetch the platform resource, but + # this allows to use custom abbreviated names which are not in the API. + PLATFORMS = { + '7g6m8erk' => 'DS', + 'wxeo3zer' => 'DSi', + '8gej2n93' => 'PC', + 'nzelkr6q' => 'PS4', + 'nzeljv9q' => 'PS4 Pro', + '5negk9y7' => 'PSP', + '7m6ylw9p' => 'Switch', + '3167lw9q' => 'Switch 2', + 'n568oevp' => 'Xbox 360', + 'o7e2mx6w' => 'Xbox One' + } + + # N series info + SERIES_ID = 'g45kx54q' + GAMES = { + 'm1mjnk12' => 'N++', + '268wr76p' => 'N+ (XBLA)', + '369y7p31' => 'N+ (DS)', + 'pd052w31' => 'N+ (PSP)', + 'y654p8de' => 'N v1.4', + 'm1mokp62' => 'N v2.0', + 'v1ponv46' => 'Cat Ext' + } + DEFAULT_GAME = 'm1mjnk12' + + MAX_REPORT_AGE = 7 * 24 * 60 * 60 # Oldest runs to consider new (i.e. notifiable) + + # Prefetch some information that hardly changes (games, categories and variables) + @@games = GAMES.map{ |k, v| [k, {}] }.to_h + + # Cache to store HTTP requests to the API for a few minutes, specially useful when + # navigating paginated lists (e.g. leaderboards). The API limit is 100 requets + # per minute, so it's hard to reach, but this way we save time anyway. + @@cache = Cache.new(name: 'speedrun') + + def get_game(game) + @@games[game] + end + + def get_category(game, cat) + @@games[game][:categories][cat] + end + + def get_variable(game, cat, var) + @@games[game][:categories][cat][:variables][var] + end + + def get_value(game, cat, var, val) + @@games[game][:categories][cat][:variables][var][:values][val] + end + + def key(route, params) + "#{route}:#{params.to_json}" + end + + def uri(route, params) + route = route.join('/') if route.is_a?(Array) + query = params.map{ |k, v| "#{k}=#{v}" }.join('&') + URI("%s/v%d/%s?%s" % [API_ROOT, API_VERSION, route, query]) + end + + def request(route, params) + uri = uri(route, params) + req = Net::HTTP::Get.new(uri) + versions = [RUBY_VERSION, ActiveRecord.version, Discordrb::VERSION] + req['User-Agent'] = "inne++ Discord Bot (#{github_link}) Ruby/%s Rails/%s discordrb/%s" % versions + req['Cache-Control'] = 'no-cache' + res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 1){ |http| + http.request(req) + } + dbg("Speedrun API request: #{uri}\nCache: #{res['x-cache']}") if SPEEDRUN_DEBUG_LOGS + res.is_a?(Net::HTTPSuccess) ? res.body.to_s : nil + rescue Timeout::Error + nil + end + + # Wrapper to look up in our local cache before actually making the request. + # The Speedrun.com CDN doesn't actually honor the 'no-cache' header, so + # we have to include a cache-busting parameter. + def get(route, params, cache: true) + params.reject!{ |key, value| value.nil? } + key = key(route, params) + params[:cache] = "%08x" % rand(2 ** 32) unless cache # Cache busting not included in key + body = cache && @@cache.get(key) || request(route, params) + return if !body + @@cache.add(key, body) + JSON.parse(body) + end + + def parse_game(data) + # Parse embedded categories and variables, if present + if data['categories'] + categories = data['categories']['data'].map{ |cat| [cat['id'], parse_category(cat)] }.to_h + else + categories = {} + end + + # Parse embedded mods, if present + moderators = data['moderators'] + if moderators.key?('data') + moderators = moderators['data'].map{ |mod| [mod['id'], parse_user(mod)] }.to_h + else + moderators = moderators.map{ |id, type| [id, { id: id, name: id, type: type }] }.to_h + end + + # Parse embedded platforms, if present. They're embedded as a hash, but otherwise + # come a list of platform IDs. Ditto for genres and developers. + platforms = data['platforms'] + if data['platforms'].is_a?(Hash) + platforms = platforms['data'].map{ |plat| [plat['id'], parse_platform(plat)] }.to_h + else + platforms.map{ |id| [id, { id: id, name: id }] }.to_h + end + + # Parse embedded genres, if present + genres = data['genres'] + if data['genres'].is_a?(Hash) + genres = genres['data'].map{ |genre| [genre['id'], parse_genre(genre)] }.to_h + else + genres.map{ |id| [id, { id: id, name: id }] }.to_h + end + + # Parse embedded developers, if present + developers = data['developers'] + if data['developers'].is_a?(Hash) + developers = developers['data'].map{ |dev| [dev['id'], parse_developer(dev)] }.to_h + else + developers.map{ |id| [id, { id: id, name: id }] }.to_h + end + + # Parse embedded levels, if present + if data['levels'] + levels = data['levels']['data'].map{ |level| [level['id'], parse_level(level)] }.to_h + else + levels = {} + end + + { + id: data['id'], + name: data['names']['international'], + abbreviation: data['abbreviation'], + alias: GAMES[data['id']], # Only for the N series ones + uri: data['weblink'], + date: Time.parse(data['release-date']), + cover: data['assets']['cover-large']['uri'], + categories: categories, + moderators: moderators, + platforms: platforms, + genres: genres, + developers: developers, + levels: levels + } + end + + def parse_developer(data) + { + id: data['id'], + name: data['name'] + } + end + + def parse_genre(data) + { + id: data['id'], + name: data['name'] + } + end + + def parse_platform(data) + { + id: data['id'], + name: data['name'], + alias: PLATFORMS[data['id']], # Only for a few we've set + date: data['released'] + } + end + + # TODO: Find a way to add the flag (Unicode emoji for countries using the code) + def parse_user(data) + return { id: data['name'], name: data['name'] } if data['rel'] == 'guest' + limbo = !data['location'] + { + id: data['id'], + name: data['names']['international'], + uri: data['weblink'], + country: !limbo ? data['location']['country']['names']['international'] : nil, + country_code: !limbo ? data['location']['country']['code'] : nil, + role: data['role'], + date: data['signup'] ? Time.parse(data['signup']) : nil + } + end + + def parse_value(id, data) + { + id: id, + name: data['label'], + description: data['rules'], # Only present when variable is a sub-category + flags: data['flags'] # Only present when variable is a sub-category + } + end + + def parse_variable(data) + { + id: data['id'], + name: data['name'], + default: data['values']['default'], # Default value for this variable (nil = none) + category: data['category'], # nil = applies to all categories + subcategory: data['is-subcategory'], # Leaderboards shown as drop-menus instead of filters + mandatory: data['mandatory'], # Must be specified on submission + custom: data['user-defined'], # User sets custom value on submission + obsoletes: data['obsoletes'], # Runs are considered for leaderboards + values: data['values']['values'].map{ |id, val| [id, parse_value(id, val)] }.to_h + } + end + + def parse_category(data) + variables = {} + variables = data['variables']['data'].map{ |var| + [var['id'], parse_variable(var)] + }.to_h if data['variables'] + + { + id: data['id'], + name: data['name'], + uri: data['weblink'], + description: data['rules'], + il: data['type'] == 'per-level', + player_count: data['players']['value'], + player_exact: data['players']['type'] == 'exactly', + misc: data['miscellaneous'], + variables: variables + } + end + + def parse_video(data) + { + uri: data['uri'] + } + end + + def parse_level(data) + { + id: data['id'], + name: data['name'], + uri: data['weblink'], + description: data['rules'] + } + end + + # Parse a raw run. The player list may be optionally provided, since in some + # cases (such as leaderboards) it's impossible to embed them directly in the runs. + def parse_run(data, players = nil) + # Parse embedded player resource, if present + if data['players'].is_a?(Hash) && data['players']['data'] + players = data['players']['data'].map{ |p| parse_user(p) } + elsif players + players = data['players'].map{ |p| + field = p['rel'] == 'user' ? :id : :name + pp = players.find{ |pp| pp[field] == p[field.to_s] } + pp || { id: p['id'], name: p['name'] || '-' } + } + else + players = data['players'].map{ |p| { id: p['id'], name: p['name'] || '-' } } + end + + # Parse video resource, if present + if !!data&.[]('videos')&.[]('links') + videos = data['videos']['links'].map{ |vid| parse_video(vid) } + else + videos = [] + end + + { + # Basic info + game: data['game'], + uri: data['weblink'], + players: players, + + # Classification of the run + category: data['category'], + level: data['level'], + variables: data['values'], + + # User provided info + description: data['comment'], + videos: videos, + + # System info + platform: data['system']['platform'], + emulated: data['system']['emulated'], + + # Times + rta: data['times']['realtime_t'], + igt: data['times']['ingame_t'], + + # Status + verified: data['status']['status'] == 'verified', + rejected: data['status']['status'] == 'rejected', + reject_reason: data['status']['reason'], + examiner: data['status']['examiner'], + + # Dates + date: (Time.parse(data['date']) rescue nil), + date_submitted: (Time.parse(data['submitted']) rescue nil), + date_verified: (Time.parse(data['status']['verify-date']) rescue nil) + } + end + + # See parse_run + def parse_leaderboard(data, players = nil, range: nil) + # Runs with a place of 0 typically don't belong to this leaderboard + count = data['runs'].count + runs = data['runs'].select{ |run| range ? range.cover?(run['place']) : run['place'] > 0 } + .map{ |run| parse_run(run['run'], players).merge(place: run['place']) } + { + game: data['game'], + category: data['category'], + values: data['values'], + uri: format_url(data['game'], data['category'], data['values']), # data['weblink'] is broken + count: count, + runs: runs + } + end + + # Retrieve game, category and variable information for all N games + def fetch_basic_info + embed = 'categories.variables,moderators,platforms,genres,developers,levels' + res = get(ROUTE_SERIES_GAMES % SERIES_ID, { embed: embed }) + return if !res + @@games = res['data'].map{ |game| [game['id'], parse_game(game)] }.to_h + end + + # Ensure the basic information (games, categories, variables, platforms) is available + def ensure_basic_info(game = default_game) + return false if !@@games.key?(game) + attempts = 0 + fetch_basic_info while (attempts += 1) <= RETRIES && @@games[game].empty? + !@@games[game].empty? + end + + # Fetch latest runs and check for new ones + # TODO: Truncate full list first, parse afterwards, to avoid parsing all runs + # TODO: Pagination doesn't work like this, due to mixing multiple lists + def fetch_runs(game, count: SPEEDRUN_NEW_COUNT, page: 0, cache: true, status: nil, order: nil, parse: true) + return [] if game && !ensure_basic_info(game) + order ||= status == 'verified' ? 'verify-date' : 'submitted' + params = { game: game, status: status, max: count, offset: count * page, orderby: order, direction: 'desc', embed: 'players' } + res = get(ROUTE_RUNS, params, cache: cache) + return [] if !res + parse ? res['data'].map{ |run| parse_run(run) } : res['data'] + end + + # Get the leaderboards for a given category, optionally filtering by platform and any variable + def fetch_boards(game, category, variables: {}, platform: nil) + return { game: game, category: category, uri: nil, runs: [] } if !ensure_basic_info(game) + params = { embed: 'players' } + params[:platform] = platform if platform + variables.each{ |var, val| params["var-#{var}"] = val } + res = get(ROUTE_LEADERBOARDS % [game, category], params) + return { game: game, category: category, uri: nil, values: {}, runs: [] } if !res + players = res['data']['players']['data'].map{ |p| parse_user(p) } if res['data']['players'] + parse_leaderboard(res['data'], players) + end + + # Check for new N runs that need to be notified + def fetch_new_runs + base_time = Time.now - MAX_REPORT_AGE + GAMES.map{ |id, name| + ['verified', 'rejected', 'new'].map{ |status| + sleep(0.5) + property = GlobalProperty.find_or_create_by(key: "last_#{status}_#{id}_speedrun") + threshold = Time.parse(property.value || base_time.to_s) + new_threshold = threshold + runs = fetch_runs(id, cache: false, status: status).select{ |run| + time = run[status == 'verified' ? :date_verified : :date_submitted] + next false if !time + new_threshold = time if time > new_threshold + time > threshold && (status != 'rejected' ? time > base_time : true) + } + property.update(value: new_threshold) + runs + } + }.flatten + end + + # Fetch any new runs (in all games), good for testing + def fetch_new_runs_test + $threshold = { + 'new' => Time.now, + 'verified' => Time.now, + 'rejected' => Time.now + } unless $threshold + ['new', 'verified', 'rejected'].map{ |status| + new_threshold = $threshold[status] + runs = fetch_runs(nil, cache: false, status: status, parse: false).select{ |run| + field = status == 'verified' ? run['status']['verify-date'] : run['submitted'] + next false if !field + time = Time.parse(field) + new_threshold = time if time > new_threshold + time > $threshold[status] && (status != 'rejected' ? time > Time.now - MAX_REPORT_AGE : true) + } + $threshold[status] = new_threshold + runs + }.flatten + end + + # Return the ID of the default game + def default_game + GAMES.invert['N++'] + end + + # Return the ID of the default category of a game + def default_category(game) + return if !ensure_basic_info(game) + get_game(game)[:categories].first[1][:id] + end + + # Return the ID of the default value of a given variable + def default_value(game, cat, var) + return if !ensure_basic_info(game) + get_variable(game, cat, var)[:default] + end + + # Checks if the game ID is valid + def validate_game(game) + @@games.key?(game) + end + + # Checks if the category ID is valid and corresponds to the given game + # Assumes the game is valid + def validate_category(game, cat) + return if !ensure_basic_info(game) + get_game(game)[:categories].any?{ |id, _| id == cat } + end + + # Checks if the variable ID is valid and corresponds to the given game and category + # Assumes the game and category are valid + def validate_variable(game, cat, var) + return if !ensure_basic_info(game) + get_category(game, cat)[:variables].any?{ |id, _| id == var } + end + + # Checks if the value ID is valid and corresponds to the given variable + # Assumes the game, category and variable are valid + def validate_value(game, cat, var, val) + return if !ensure_basic_info(game) + get_variable(game, cat, var)[:values].any?{ |id, _| id == val } + end + + # Ensure the game ID is valid, default if not + def sanitize_game(game) + validate_game(game) ? game : default_game + end + + # Ensure the category ID is valid, default if not + def sanitize_category(game, cat) + return if !ensure_basic_info(game) + validate_category(game, cat) ? cat : default_category(game) + end + + # Ensure the variable ID is valid, default _TO NIL_ if not + def sanitize_variable(game, cat, var) + return if !ensure_basic_info(game) + validate_variable(game, cat, var) ? var : nil + end + + # Ensure the value ID is valid, default if not + def sanitize_value(game, cat, var, val) + return if !ensure_basic_info(game) + validate_value(game, cat, var, val) ? val : default_value(game, cat, var) + end + + # Build the Speedrun web URL for the corresponding leaderboards + def format_url(game, category = nil, values = {}) + url = "#{WEB_ROOT}/#{@@games[game][:abbreviation]}" + return url if !category + url << '?x=' << category + return url if values.empty? + url << '-' << values.map{ |var, val| "#{var}.#{val}" }.join('-') + url + end + + # Format a run into usable textual fields + def format_run(run) + # Fetch main components of the run + game = @@games[run[:game]] + category = game[:categories][run[:category]] + platform = game[:platforms][run[:platform]] + level = !!run[:level] ? game[:levels][run[:level]] : nil + variables = run[:variables].map{ |var_id, val_id| + category[:variables][var_id][:values][val_id][:name] + }.join(', ') + + # Format fields + { + game: game[:alias] || game[:name], + platform: platform[:alias] || platform[:name], + mode: category[:il] ? 'IL' : category[:il].nil? ? '?' : 'RTA', + category: category[:name], + type: !!level ? level[:name] : !run[:variables].empty? ? variables : '-', + players: run[:players].map{ |p| p[:name] }.join(', '), + rta: format_timespan(run[:rta], ms: true, iso: true), + igt: format_timespan(run[:igt], ms: true, iso: true), + date: (run[:date_submitted].strftime('%Y/%m/%d') rescue 'Unknown'), + status: run[:verified] ? 'VER' : run[:rejected] ? 'REJ' : 'NEW', + color: run[:verified] ? ANSI::GREEN : run[:rejected] ? ANSI::RED : ANSI::YELLOW, + emoji: run[:verified] ? '✅' : run[:rejected] ? '❌' : '💥' + } + end + + # Format latest runs + def format_table(runs, color: true, emoji: false) + # Draft table fields + colors = [] + runs.map!{ |run| + run = format_run(run) + run[:status] += ' ' + run[:emoji] if emoji + colors << run[:color] + run.values.take(10) + } + + # Compute padding (not necessary anymore since we're using make_table) + header = ["Game", "System", "Mode", "Category", "Type", "Players", "RTA", "IGT", "Date", "Status"] + align = ['-', '-', '-', '-', '-', '-', '', '', '-', ''] + max_padding = [7, 8, 4, 32, 32, 32, 12, 12, 10, 12] + padding = header.map(&:length) + padding = runs.transpose.lazy.zip(padding, max_padding).map{ |col, min, max| + col.max_by(&:length).length.clamp(min, max) + }.force if !runs.empty? + + # Format rows (add header, apply padding and coloring) and craft table + runs.prepend(header) + runs.each{ |run| + run.map!.with_index{ |field, i| "%#{align[i]}*.*s" % [padding[i], padding[i], field] } + } + runs[1..-1].each.with_index{ |run, i| run[-1] = ANSI.format(run[-1], bold: true, fg: colors[i]) } if color + runs[0].map!{ |field| ANSI.bold + field + ANSI.none } + runs.insert(1, :sep) + make_table(runs) + end + + # Format the leaderboards + def format_boards(boards) + game = get_game(boards[:game]) + cat = get_category(boards[:game], boards[:category]) + header = "📜 #{GAMES[game[:id]]} Speedruns — #{cat[:name]}" + vars = boards[:values].map{ |var, val| get_value(boards[:game], boards[:category], var, val)[:name] }.join(', ') + header += " (#{vars})" unless vars.empty? + list = boards[:runs].map{ |run| + fields = format_run(run).merge(place: run[:place]) + place_emoji = case game[:alias] + when 'N++' + 'plus' + when 'N+ (DS)' + 'ds' + when 'N v1.4', 'N v2.0' + 'gold' + else + 'trophy' + end + '_' + run[:place].ordinalize + place = $emojis[place_emoji] || EMOJI_NUMBERS[run[:place]] || "**#{run[:place]}**" + players = mdurl('**' + run[:players].map{ |p| p[:name] }.join(', ') + '**', run[:uri]) + time = fields[:rta] + (fields[:igt] != '-' ? " (#{fields[:igt]} IGT)" : '') + time = verbatim(time) + plat_emoji = nil + ['PC', 'PSP', 'PS', 'Xbox', 'Switch', '2DS', '3DS', 'DS'].each{ |plat| + break plat_emoji = $emojis["plat_#{plat}"] if fields[:platform][/#{plat}/i] + } + plat_emoji = '💻' if ['N v1.4', 'N v2.0'].include?(GAMES[game[:id]]) + platform = plat_emoji || "(#{fields[:platform]})" + "%s %s: %s — %s %s" % [place, players, time, fields[:date], platform] + }.join("\n") + embed = Discordrb::Webhooks::Embed.new( + title: header, + description: list, + url: boards[:uri], + color: SPEEDRUN_COLOR_INFO, + thumbnail: Discordrb::Webhooks::EmbedThumbnail.new(url: game[:cover]) + ) + embed + end + + # Formats a single run as an embed + def format_embed(run, emoji: true) + # Format fields + game = @@games[run[:game]] + fields = format_run(run) + examiner = game[:moderators][run[:examiner]] + examiner = "[`#{examiner[:name]}`](#{examiner[:uri]})" if examiner + player_url = run[:players][0][:uri] + type = !!run[:level] ? 'Level' : !run[:variables].empty? ? 'Subcategory' : '-' + + # Distinguish by status + if run[:verified] + date = run[:date_verified] ? ' on ' + run[:date_verified].strftime('%Y/%m/%d') : '' + color = SPEEDRUN_COLOR_VER + title = "New #{fields[:game]} speedrun verified!" + desc = "Run verified by #{examiner}#{date}." + elsif run[:rejected] + color = SPEEDRUN_COLOR_REJ + title = "New #{fields[:game]} speedrun rejected!" + desc = "Run rejected by #{examiner} due to:\n" + format_block(run[:reject_reason]) + else + color = SPEEDRUN_COLOR_NEW + title = "New #{fields[:game]} speedrun submitted!" + desc = 'Run pending verification.' + end + title.prepend(fields[:emoji] + ' ') if emoji + + # Build embed object + embed = Discordrb::Webhooks::Embed.new( + title: title, + description: desc, + url: run[:uri], + color: color, + timestamp: run[:date_submitted], + author: Discordrb::Webhooks::EmbedAuthor.new(name: fields[:players], url: player_url), + footer: Discordrb::Webhooks::EmbedFooter.new(text: 'Submitted to Speedrun.com', icon_url: 'https://www.speedrun.com/images/1st.png'), + thumbnail: Discordrb::Webhooks::EmbedThumbnail.new(url: game[:cover]) + ) + + # Add inline fields + embed.add_field(inline: true, name: 'Game', value: fields[:game]) + embed.add_field(inline: true, name: 'Category', value: fields[:category]) + embed.add_field(inline: true, name: type , value: fields[:type]) unless type == '-' + embed.add_field(inline: true, name: 'System', value: fields[:platform]) + embed.add_field(inline: true, name: 'RTA', value: fields[:rta]) + embed.add_field(inline: true, name: 'IGT', value: fields[:igt]) + embed + end + +end + +#------------------------------------------------------------------------------# +# STEAM TICKET DOCUMENTATION | +#------------------------------------------------------------------------------# +# Parts | +# Game Connect token (24 bytes): Identifies each session | +# Session Header (28 bytes): Additional session information | +# Ownership ticket (>46 bytes): Proves ownership of app | +#------------------------------------------------------------------------------# +# Game Connect token | +# 4B - Token size (always 20) | +# 8B - GC token (seemingly random) | +# 8B - SteamID64 | +# 4B - Token generation timestamp | +#------------------------------------------------------------------------------# +# Session header | +# 4B - Header size (always 24) | +# 4B - ? (always 1) | +# 4B - ? (always 2) | +# 4B - IP of Steam node we connected to? | +# 4B - ? | +# 4B - Time connected in ms | +# 4B - Connection count with this ticket | +#------------------------------------------------------------------------------# +# Ownership ticket | +# 4B - Complete size incl. signature (only present in full tickets) | +# 4B - Ticket size including itself | +# 4B - Ticket version (currently 4) | +# 8B - SteamID64 | +# 4B - App ID (230270 for N++) | +# 4B - External IP | +# 4B - Internal IP | +# 4B - License flags (usually 2) | +# 4B - Ticket generation timestamp | +# 4B - Ticket expiration timestamp (21 days) | +# 2B - License count | +# ## - License IDs (4B x count, 94152 for N++) | +# 2B - DLC count | +# ## - DLC list (ID + license count + license IDs each) | +# 2B - Reserved / padding | +# 128B - Ownership ticket signature (see verify_signature) | +#------------------------------------------------------------------------------# +# Notes | +# - Ownership tickets are issued on command and prove ownership for Steam ID | +# ID / App ID pair. They expire in 21 days and we reuse them if possible. | +# - Authentication tickets include the other blocks which are session | +# dependent. GC tokens are issued automatically by Steam on login and in | +# other ocassions, such as starting a game. The whole ticket is validated | +# and at that point it expires in about 5 minutes. This is then sent to | +# Metanet's server to authenticate, we remain authenaticated for 1 hour. | +# - We use a custom Python utility (util/auth.py) to talk to Steamworks's | +# API using the steam-py library to generate the tokens and validate the | +# full tickets. We login using refresh tokens which are stored as env vars | +#------------------------------------------------------------------------------# + +class SteamTicket < ActiveRecord::Base + # The ticket length is variable, for N++ they're currently 240 bytes + TOKEN_LENGTH = 20 + SESSION_LENGTH = 24 + MIN_TICKET_LENGTH = 230 + + # Exit statuses of the Python authentication script + EXIT_OK = 0 + EXIT_NO_CREDENTIALS = 1 + EXIT_NO_OWNERSHIP_TICKET = 2 + EXIT_NO_AUTHENTICATION_TICKET = 3 + + # Make parsed attributes available after instantiation like regular attributes + attr_accessor :token, :token_time, :conn_ip, :conn_duration, + :conn_count, :version, :external_ip, :internal_ip, + :flags, :created_at, :expires, :licenses, + :dlcs, :signature + after_save :parse + after_initialize :parse + + # Generate a new ticket by running a Python util that connects to Steam's API + def self.generate(app_id, username: nil, password: nil, token: nil, ticket: nil, file: nil) + dbg("Requesting new Steam ticket for #{app_id}...") + stdout, stderr, status = steamworks(app_id, username: username, password: password, token: token, ticket: ticket, file: file) + return if status.nil? + return err("Steam credentials expired or unavailable", discord: true) if status.exitstatus == EXIT_NO_CREDENTIALS + return if !status.success? || stdout.blank? + add_ascii(stdout.strip) + end + + # Parse a raw ticket into a struct with the same field names as the ticket object + def self.parse(ticket) + # Basic integrity checks + if ticket.length < MIN_TICKET_LENGTH + err("Ticket is too short (#{ticket.length} < #{MIN_TICKET_LENGTH})") + return + end + + # Read fields as a buffer + buffer = StringIO.new(ticket) + _, token, _, token_time = ioparse(buffer, 'L e + lex(e, 'Failed to parse Steam ticket') + nil + end + + # Parse a ticket in ASCII form, which is how they're typically sent over the wire + def self.parse_ascii(ticket) + parse([ticket].pack('H*')) + end + + # Creates a new ticket or updated the one corresponding to this user / app pair. + def self.add(ticket, date = Time.now) + dbg("Parsing Steam ticket...") + fields = parse(ticket) + return nil if !fields + dbg("Creating Steam ticket...") + obj = find_or_create_by(steam_id: fields.steam_id, app_id: fields.app_id) + obj.update(ticket: ticket, date: date) + obj + end + + # Create a new ticket object from an ASCII ticket + def self.add_ascii(ticket) + add([ticket].pack('H*')) + end + + # The ownership ticket portion is signed by Steam using their system public key. + # The method is 1024bit RSA-SHA1 so the sig should be 128 bytes. + def self.verify_signature(data, signature) + OpenSSL::PKey::RSA.new(File.read(PATH_STEAM_KEY)).verify('SHA1', signature, data) + end + + # Extract the ownership ticket part, which has an expiration period of 21 days + # Optionally also include the signature + def ownership_ticket(sign = false) + offset = TOKEN_LENGTH + SESSION_LENGTH + 12 + size = ticket.unpack1('L<', offset: offset) + ticket[offset, size + (sign ? 128 : 0)] + end + + # Check Steam's signature is present and valid + def signed? + signature && self.class.verify_signature(ownership_ticket, signature) + end + + # We leave 5 minutes to spare, no point reusing a ticket at that point + def expired? + Time.now > expires - 5 * 60 + end + + # Dump ticket in ASCII + def ascii + ticket.unpack1('H*').upcase + end + + # Refresh the token of this ticket. If expired, generates a fresh ticket altogether. + def refresh(username: nil, password: nil, token: nil, file: nil) + tkt = !expired? ? ownership_ticket(true).unpack1('H*') : nil + self.class.generate(app_id, username: username, password: password, token: token, ticket: tkt, file: file) + end + + private + + # This function will run when the object is initialized, thus giving access to + # the new attributes that are not present in the db directly but rather parsed + # from the raw ticket afterwards + def parse + return unless ticket + self.class.parse(ticket).each_pair{ |name, value| + next if ['steam_id', 'app_id', 'ticket'].include?(name) + self.send("#{name}=".to_sym, value) + } + end +end + +# An app represents each product on Steam, such as N++ (230270). +# https://partner.steamgames.com/doc/store/application +# https://steamdb.info/app/230270/ +class SteamApp < ActiveRecord::Base + has_many :steam_branches, foreign_key: :app_id + has_many :steam_depots, foreign_key: :app_id + has_many :steam_builds, foreign_key: :app_id + has_many :steam_achievements, foreign_key: :app_id + alias_method :branches, :steam_branches + alias_method :depots, :steam_depots + alias_method :builds, :steam_builds + alias_method :achievements, :steam_achievements + + def fetch_info(username: nil, password: nil, token: nil) + # Request app info from Steamworks + dbg("Steamworks: Fetching app info #{id}") + stdout, stderr, status = steamworks(id, username: username, password: password, token: token, info: true) + return if status.nil? || !status.success? || stdout.blank? + json = JSON.load(stdout) + new_stuff = [] + + # Update basic app info + info = json['info'] + update( + changenum: info['changenum'], + name: info['name'], + sha: [info['sha']].pack('H*'), + date: Time.parse(info['date']), + url: info['url'], + developer: info['developers'].join(', '), + free: info['free'], + os_win: info['platforms']['windows'], + os_linux: info['platforms']['linux'], + os_mac: info['platforms']['mac'] + ) + + # Update technical app info + json['branches'].each do |h_branch| + # Detect new branches and update info (mainly build_id and date) + branch = SteamBranch.find_or_create_by(app_id: id, name: h_branch['name'], deleted: nil) do |b| + b.created = Time.parse(h_branch['date']) + new_stuff << b + succ("New branch #{h_branch['name']} found") + end + branch.update( + description: h_branch['description'] || branch.description, + official_id: h_branch['build_id'], + updated: Time.parse(h_branch['date']), + private: h_branch['private'] + ) + + # Parse depots + content = h_branch['depots'].map{ |h_depot| + # Detect depots that are either new or re-added to the app + depot = SteamDepot.find_or_create_by(id: h_depot['id'], removed: nil) do |d| + d.name = h_depot['name'], + d.app_id = id, + d.max_size = h_depot['max_size'], + d.shared = h_depot['shared_install'], + d.system = h_depot['system_defined'], + d.os_win = h_depot['config']['oslist'].include?('windows'), + d.os_linux = h_depot['config']['oslist'].include?('linux'), + d.os_mac = h_depot['config']['oslist'].include?('macos'), + d.added = Time.now + new_stuff << d + succ("New depot #{h_depot['id']} found") + end + + # Detect new manifests, or manifests that we didn't previusly fetch + manifest = SteamManifest.find_or_create_by(gid: h_depot['manifest'], depot_id: h_depot['id']) do |m| + m.date = Time.now + new_stuff << m + succ("New manifest #{h_depot['manifest']} for depot #{h_depot['id']} found") + end + manifest.fetch(username: username, password: password, token: token, branch: h_branch['name']) if !manifest.count + + [depot, manifest] + }.to_h + + # Detect new builds + md5 = SteamBuild._hash(content.map{ |d, m| [d.id, m.gid] }.to_h) + build = SteamBuild.find_or_create_by(app_id: id, md5: md5) + if build.previously_new_record? + content.each{ |depot, manifest| + SteamBuildContent.create(build_id: build.id, depot_id: depot.id, manifest_id: manifest.id) + } + succ("New build found: #{md5.unpack1('H*').upcase}") + end + + # Detect new branch versions + version = branch.version + is_new_content = version.build_id != build.id + is_new_build_id = version.official_id != h_branch['build_id'] + if is_new_build_id || is_new_content + version = SteamBranchVersion.create( + branch_id: branch.id, + build_id: build.id, + official_id: h_branch['build_id'], + date: Time.parse(h_branch['date']) + ) + new_stuff << version if is_new_build_id + succ("New version on ID #{h_branch['build_id']} for branch #{h_branch['name']} found") + end + end + + # Detect removed branches + branch_names = json['branches'].map{ |branch| branch['name'] } + removed_branches = SteamBranch.where(app_id: id, deleted: nil).where.not(name: branch_names) + removed_branches.update_all(deleted: Time.now) + removed_branches.all.to_a.map!(&:embed_deleted) + + # Detect removed depots + depot_ids = json['branches'].map{ |branch| branch['depots'].map{ |depot| depot['id'] } }.flatten.uniq.sort + removed_depots = SteamDepot.where(app_id: id, removed: nil).where.not(id: depot_ids) + removed_depots.update_all(removed: Time.now) + removed_depots.all.to_a.map!(&:embed_deleted) + + # Report changes + new_stuff.each(&:reload) + new_stuff.map!(&:embed_new) + to_report = new_stuff + removed_branches + removed_depots + dbg("Found no new Steam patches") if to_report.empty? + channel = !TEST ? find_channel(id: CHANNEL_DEBUG) : botmaster.pm + to_report.each_slice(DISCORD_EMBED_LIMIT){ |embeds| + send_message(channel, embeds: embeds) + sleep(0.5) + } + to_report.size + rescue => e + lex(e, "Steamworks: Failed to fetch app info #{id}") + end + + def default_branch + branches.find_by(name: STEAM_BRANCH) + end +end + +# Apps on Steam can be developed on multiple branches, just like git projects. +# Branches can be public or private (password-protected), and there's always at +# least one branch, the default "public" branch. +# N++ currently has 3 public branches: public, test and localisation. +# https://partner.steamgames.com/doc/store/application/branches +# https://steamdb.info/app/230270/depots/ +class SteamBranch < ActiveRecord::Base + has_many :steam_branch_versions, -> { order(:date) }, foreign_key: :branch_id + belongs_to :steam_app, foreign_key: :app_id + alias_method :versions, :steam_branch_versions + alias_method :app, :steam_app + + # Fetch current version of the branch + def version + versions.last + end + + # Versions where the build ID was changed + # TODO: For performance, we should do this with SQL (e.g. LAG) + def patches + versions.chunk_while{ |a, b| a.official_id == b.official_id }.map(&:first) + end + + # Fetch all depots included in this branch + def depots + version.build.depots + end + + def steamdb + STEAMDB_URI_BRANCH % [app_id, name] + end + + def embed(title, timestamp = Time.now) + branch_link = mdurl(name, steamdb, false) + build_link = mdurl(official_id.to_s, version.steamdb, false) + depot_links = depots.map{ |depot| mdurl("#{depot.os_icons} #{depot.id}", depot.steamdb, false) }.join(' ') + embed = Discordrb::Webhooks::Embed.new( + title: title, + url: steamdb, + color: STEAMDB_COLOR_BRANCH, + timestamp: timestamp + ) + embed.add_field(inline: true, name: 'Name', value: branch_link) + embed.add_field(inline: true, name: 'Build', value: build_link) + embed.add_field(inline: true, name: 'Depots', value: depot_links) + embed.add_field(inline: false, name: 'Description', value: description || 'None') + embed + end + + def embed_new + embed("🌿 New Steam branch created", created || versions.minimum(:date)) + end + + def embed_deleted + embed("🌿 Steam branch deleted", deleted) + end +end + +# Apps on Steam can be divided into depots, for organization. Common uses are to +# separate releases by architecture, OS or language. Thus, when fetching an app, +# not all depots are necessary, only those specific to your setup. +# N++ currently has 3 depots, of which you only need 1: +# 230271 (Windows Base), 230272 (OSX Base) and 230273 (Linux Base). +# https://partner.steamgames.com/doc/store/application/depots +# https://steamdb.info/app/230270/depots/ +class SteamDepot < ActiveRecord::Base + has_many :steam_build_contents, foreign_key: :depot_id + belongs_to :steam_app, foreign_key: :app_id + alias_method :contents, :steam_build_contents + alias_method :app, :steam_app + + # Calculates all individual changes of this depot in the given branch and + # returns them as (date, version_id, manifest_id) tuples. + def changes(branch: nil, encrypted: false) + SteamBranchVersion.joins("INNER JOIN steam_build_contents ON steam_build_contents.build_id = steam_branch_versions.build_id") + .where("steam_branch_versions.branch_id = #{branch.id} AND steam_build_contents.depot_id = #{id}") + .order(:date) + .pluck(:date, :id, 'steam_build_contents.manifest_id') + .chunk_while{ |a, b| a[2] == b[2] } + .map(&:first) + end + + # All depot's manifests without duplicates, sorted by when they were first found, as a Relation. + def manifests(encrypted: false) + query = SteamManifest.where(depot_id: id).order(:date) + query = query.where.not(gid: nil) unless encrypted + query + end + + # Current manifest of the depot in the given branch + def manifest(branch) + branch.version.build.contents.find_by(depot_id: id).manifest + end + + def steamdb + STEAMDB_URI_DEPOT % id + end + + def os_icons + icons = [] + icons << $emojis['os_win'] if os_win + icons << $emojis['os_linux'] if os_linux + icons << $emojis['os_mac'] if os_mac + icons.join + end + + def embed(title, timestamp = Time.now, branch: nil) + branch = app.default_branch unless branch + depot_link = mdurl(id.to_s, steamdb, false) + embed = Discordrb::Webhooks::Embed.new( + title: title, + url: steamdb, + color: STEAMDB_COLOR_DEPOT, + timestamp: Time.now + ) + embed.add_field(inline: true, name: 'ID', value: depot_link) + embed.add_field(inline: true, name: 'OS', value: os_icons) + embed.add_field(inline: true, name: 'Size', value: format_size(manifest(branch).size_raw)) + embed + end + + def embed_new + embed("📦 New Steam depot added", added) + end + + def embed_deleted + embed("📦 Steam depot removed", removed) + end +end + +# A manifest is a snapshot of a depot at a given point. It contains the list of +# files and other related metadata, which unfortunately doesn't include the date. +# Manifests can be reused in different branches, which simply means that the +# depot is the same on both branches, but they are depot-specific. Manifests +# can also be encrypted when present on private (password-requiring) branches. +# Manifests are identified by a randomly generated 64bit integer GID, which is +# a 128bit blob when encrypted. History-matching could perhaps be used to match +# public and encrypted manifests, but I've given up on this idea because the +# metadata (e.g. buildID) isn't consistent enough to be certain. +# https://partner.steamgames.com/doc/store/application/builds +# https://steamdb.info/depot/230271/manifests/ +# https://github.com/DoctorMcKay/node-steam-user/wiki/Steam-CDN-Client +class SteamManifest < ActiveRecord::Base + has_many :steam_build_contents, foreign_key: :manifest_id + belongs_to :steam_depot, foreign_key: :depot_id + alias_method :contents, :steam_build_contents + alias_method :depot, :steam_depot + + # Fetch manifest metadata from Steamworks. For some reason we need to specify + # the branch, even though manifests are branch-agnostic. + def fetch(username: nil, password: nil, token: nil, branch: nil) + log("Steamworks: Fetching manifest #{gid}") + return err("Cannot fetch encrypted manifest") if encrypted? + return err("Cannot fetch manifest, depot unknown") if !depot + if !branch + return err("Cannot fetch manifest, no known branches") if !branches.last + branch = branches.last.name + end + stdout, stderr, status = steamworks( + depot.app.id, username: username, password: password, token: token, + branch: branch, depot: depot.id, manifest: gid, timeout: 20 + ) + return if status.nil? || !status.success? || stdout.blank? + json = JSON.load(stdout) + update( + name: name || json['name'], + count: json['count'], + size_raw: json['size_raw'], + size_compressed: json['size_compressed'] + ) + rescue => e + lex(e, "Steamworks: Failed to fetch manifest #{gid}") + end + + def encrypted? + gid.nil? && !encrypted_gid.nil? + end + + def versions + SteamBranchVersion.joins("INNER JOIN steam_build_contents ON steam_build_contents.build_id = steam_branch_versions.build_id") + .where("steam_build_contents.manifest_id = #{id}") + end + + def branches + SteamBranch.where(id: versions.pluck(:branch_id)) + end + + def compressed? + size_compressed != size_raw + end + + def size + compressed? ? size_compressed : size_raw + end + + def steamdb + STEAMDB_URI_MANIFEST % [depot.id, gid] + end + + def embed(title, color) + manifest_link = mdurl(gid.to_s, steamdb, false) + depot_link = mdurl("#{depot.os_icons} #{depot.id}", depot.steamdb, false) + build_links = versions.map{ |v| mdurl(v.official_id.to_s, v.steamdb, false) }.uniq.join(', ') + branch_links = branches.map{ |branch| mdurl(branch.name, branch.steamdb, false) }.join(', ') + embed = Discordrb::Webhooks::Embed.new( + title: title, + url: steamdb, + color: color, + timestamp: date || Time.now + ) + embed.add_field(inline: true, name: 'Branches', value: branch_links) + embed.add_field(inline: true, name: 'Depot', value: depot_link) + embed.add_field(inline: true, name: 'Builds', value: build_links) + embed.add_field(inline: true, name: 'Filecount', value: count.to_s) + embed.add_field(inline: true, name: 'DL size', value: format_size(size)) + embed.add_field(inline: true, name: 'Manifest', value: manifest_link) + embed + end + + def embed_new + embed("📜 New Steam patch submitted", nil) + end +end + +# A build is a snapshot of an entire branch at a given point, they are identified +# by a global incremental counter (the BuildID). Thus, a build can be thought of +# as a mapping between depots and manifests: it specifies what manifest each +# depot had at that time. This is the closest to a "game version", although +# patches can be even more granular: manifests can be changed without generating +# a new build. +# +# Multiple branches can be pointed to the same build, in which case they should +# identical at that point (bar metadata). However, since the buildID is just +# metadata, on rare occasions you can see branches on the same build but with +# different depots or manifests (e.g. 1812834). This happens even more if you +# consider private branches, since encrypted manifests might have different GIDs +# but actually be the same. +# +# Thus, instead of identifying builds by their official ID, I'm doing so by +# hashing their contents (depot -> manifest mapping), even when their official +# buildIDs match. +# https://partner.steamgames.com/doc/store/application/builds +# https://steamdb.info/app/230270/patchnotes/ +class SteamBuild < ActiveRecord::Base + has_many :steam_build_contents, foreign_key: :build_id, dependent: :destroy + has_many :steam_branch_versions, foreign_key: :build_id + belongs_to :steam_app, foreign_key: :app_id + alias_method :contents, :steam_build_contents + alias_method :versions, :steam_branch_versions + alias_method :app, :steam_app + + # Hash the contents of a build. They should be provided as a hash mapping + # depot IDs to manifest GIDs. For public manifests the GID is a 64bit integer, + # for encrypted ones it's a 128bit binary string. + def self._hash(content) + md5(content.sort_by(&:first).map{ |d_id, m_id| "#{d_id}:#{m_id}" }.join(',')) + end + + # Find surface changes (manifests added, changed or removed) as a hash keyed by depots + def self.diff(b1, b2) + map1 = b1&.mapping || {} + map2 = b2&.mapping || {} + keys = (map1.keys + map2.keys).uniq.sort_by{ |d| d.id } + keys.map{ |d| [d, {old: map1[d], new: map2[d]}] }.reject{ |d, c| c[:old] == c[:new] }.to_h + end + + # Return contents as a hash, mapping depots to manifests + def mapping + contents.map{ |c| [c.depot, c.manifest] }.to_h + end + + # A private build has at least one encrypted manifest + def private? + !manifests.where(gid: nil).empty? + end + + # All branches that used this build at some point + def branches + SteamBranch.where(id: versions.pluck(:branch_id)) + end + + # All depots included in this build + def depots + SteamDepot.where(id: contents.pluck(:depot_id)) + end + + # All manifests present in this build + def manifests + SteamManifest.where(id: contents.pluck(:manifest_id)) + end +end + +# This class doesn't directly map to any Steam object. Rather, it stores the +# mapping between depots and manifests. Its elements are essentially tuples +# of the form (build_id, depot_id, manifest_id). +class SteamBuildContent < ActiveRecord::Base + belongs_to :steam_build, foreign_key: :build_id + belongs_to :steam_depot, foreign_key: :depot_id + belongs_to :steam_manifest, foreign_key: :manifest_id + alias_method :build, :steam_build + alias_method :depot, :steam_depot + alias_method :manifest, :steam_manifest +end + +# This class also doesn't map to any Steam object. It stores the version history +# of each branch so that we can trace it. Its elements are essentially tuples +# of the form (branch_id, build_id, official_id, date). +class SteamBranchVersion < ActiveRecord::Base + belongs_to :steam_branch, foreign_key: :branch_id + belongs_to :steam_build, foreign_key: :build_id + alias_method :branch, :steam_branch + alias_method :build, :steam_build + + # Previous version (chronologically) of this branch. Ignore transitional builds by default. + def previous(official = true) + SteamBranchVersion.where("branch_id = #{branch_id} AND date < '#{date}'") + .order(date: :desc).first + end + + # Next version (chronologically) of this branch. Ignore transitional builds by default. + def next(official = true) + SteamBranchVersion.where("branch_id = #{branch_id} AND date > '#{date}'") + .order(:date).first + end + + # Changes in this version + def diff + SteamBuild.diff(previous&.build, build) + end + + # Whether this branch updat was a rollback according to SteamDB (i.e. the buildID + # is reduced). An actual build being reused doesn't necessarily count then. + def rollback? + return false unless prev = previous + official_id < prev.official_id + end + + # Fetch the manifest for the given repo in this version + def manifest(depot) + build.contents.find_by(depot_id: depot.id)&.manifest + end + + # Depots that were actually changed in this version + def depots + diff.keys + end + + # Manifests that were actually changed in this version + def manifests + diff.values.map{ |c| c[:new] }.compact + end + + def steamdb + STEAMDB_URI_BUILD % official_id + end + + def embed(title) + build_link = mdurl(official_id.to_s, steamdb, false) + branch_link = mdurl(branch.name, branch.steamdb, false) + depot_links = depots.map{ |depot| mdurl("#{depot.os_icons} #{depot.id}", depot.steamdb, false) }.join(' ') + manifest_links = manifests.map{ |manifest| mdurl(manifest.gid.to_s, manifest.steamdb, false) }.join(', ') + embed = Discordrb::Webhooks::Embed.new( + title: title, + url: steamdb, + color: STEAMDB_COLOR_BUILD, + timestamp: date + ) + embed.add_field(inline: true, name: 'ID', value: build_link) + embed.add_field(inline: true, name: 'Branch', value: branch_link) + embed.add_field(inline: true, name: 'Updated depots', value: depot_links) + embed.add_field(inline: false, name: 'New patches', value: manifest_links) + embed + end + + def embed_new + embed("⚙️ New Steam build published") + end +end + +# Represents the achievements of a game, self-explanatory. N++ currently has 33. +# https://partner.steamgames.com/doc/features/achievements +# https://steamdb.info/app/230270/stats/ +class SteamAchievement < ActiveRecord::Base + belongs_to :steam_app, foreign_key: :app_id + alias_method :app, :steam_app + alias_method :app=, :steam_app= +end + +# Encapsulates functionality to parse data from SteamDB, mainly for seeding the +# Steam classes above for the first time +module SteamDB extend self + + # Parse SteamDB's updates list (https://steamdb.info/app/230270/history/) + def seed_updates(html, app_id: APP_ID) + # Auxiliary struct to parse individual changes to any field. A change can be of 3 types: + # additions, modifications and deletions. The key identifies the field, followed by + # the old and new value. Additions only have new values, and deletions only have old values. + changeStruct = Struct.new(:type, :key, :old, :new, :valid) + + # Hashes to temporarily store data before bulk-inserting into db + branches = Hash.new{ |hash, key| + branch = hash.find{ |k, v| v[:name] == key && v[:deleted].nil? } + next branch.last if branch + id = hash.size + 1 + hash[id] = { + id: id, app_id: app_id, name: key, description: nil, official_id: nil, + created: nil, updated: nil, deleted: nil, private: false + } + } + depots = Hash.new{ |hash, key| + hash[key] = { + id: key, app_id: app_id, name: nil, max_size: nil, shared: false, system: false, + os_win: false, os_linux: false, os_mac: false, added: nil, removed: nil + } + } + manifests = Hash.new{ |hash, key| + manifest = { + id: hash.size + 1, gid: nil, encrypted_gid: nil, depot_id: nil, + name: nil, count: nil, size_raw: nil, size_compressed: nil, date: nil + } + manifest[key.is_a?(Integer) ? :gid : :encrypted_gid] = key + hash[key] = manifest + } + builds = Hash.new{ |hash, key| hash[key] = { id: hash.size + 1, app_id: app_id, md5: key } } + contents = [] + versions = [] + + # Store current branch state (build ID, depot -> manifest mapping) + current_official_id = {} + current_manifests = Hash.new{ |hash, key| hash[key] = {} } + + # Auxiliary function to parse each individual change (addition, modification, deletion) + # The argument should be a leaf li element. + parse_change = Proc.new do |block| + # Ensure change is of one of the valid types + change = changeStruct.new(nil, '', '', '', false) + ['diff-removed', 'diff-added', 'diff-modified'].each{ |klass| + change.type = klass[5..].to_sym if block.classes.include?(klass) + } + next change unless change.type + + # Key identifies field being changed + key = block.at('span>i') + next change unless key + change.key = key.content.strip.chop + + # Additions and modifications should have a new value + if change.type == :added || change.type == :modified + value = block.at('ins, .ins') + next change unless value + change.new = value.content.scrub('').strip + end + + # Deletions and modifications should have an old value + if change.type == :removed || change.type == :modified + value = block.at('del, .del') + next change unless value + change.old = value.content.scrub('').strip + end + + # Parsing successful + change.valid = true + next change + rescue => e + err("Failed to parse SteamDB change: #{block.path}") + else + alert("Invalid SteamDB change found: #{block.path}") if !change.valid + alert("Empty SteamDB change found: #{block.path}") if change.valid && change.old.empty? && change.new.empty? + end + + # Sets and counters, for logging purposes + unknown_keys = Set.new + special_sets = Hash.new{ |hash, key| hash[key] = [] } + counters = Hash.new(0) + + # Parse SteamDB history page + panels = Nokogiri::HTML(html).search('div.panel-history') + total = panels.size + panels.reverse_each.with_index do |panel, i| + dbg("Parsing history panel #{i + 1} / #{total}...", progress: true) + counters[:panels] += 1 + + # Skip changes that only update the changenumber itself + # Also skip changes outside of PICS since they only affect assets and such + changeid = panel['data-changeid'] + is_pics = !changeid[/^U:/] + has_stuff = !panel.classes.include?('history-changelist-only') + next if !is_pics || !has_stuff + counters[:parsed_panels] += 1 + datetime = Time.parse(panel.at('[datetime]')['datetime']) + + # Parse list of changes + panel.at('ul.app-history').search('>li').each do |item_block| + + # Ignore all items except Depot ones + counters[:items] += 1 + next unless item_block.classes.include?('repo') + counters[:depot_items] += 1 + item_block.at('ul.app-history').search('>li').each do |branch_block| + counters[:subitems] += 1 + + # Isolated depot changes + if !(branch_block.classes & ['diff-added', 'diff-modified', 'diff-removed']).empty? + # Again, only parse depot changes + counters[:leaf_subitems] += 1 + change = parse_change[branch_block] + if !change&.valid || !change.key[/Depot\s+(\d+)/i] + special_sets[:other_leafs] << change.key + next + end + counters[:depot_leafs] += 1 + depot = depots[$1.to_i] + depot[:added] = datetime unless depot[:added] && depot[:added] < datetime + + # Parse addition or deletion + case change.key + when /name/i + depot[:name] = change.new unless change.new.empty? + when /config\/oslist/i + depot[:os_win] = false if !!change.old[/windows/i] + depot[:os_linux] = false if !!change.old[/linux/i] + depot[:os_mac] = false if !!change.old[/macos/i] + depot[:os_win] = true if !!change.new[/windows/i] + depot[:os_linux] = true if !!change.new[/linux/i] + depot[:os_mac] = true if !!change.new[/macos/i] + when /config\/language/ + # Ignore + when /depotfromapp/i + # Ignore + when /maxsize/i + depot[:max_size] = change.new.to_i unless depot[:max_size] && depot[:max_size] > change.new.to_i + when /systemdefined/i + depot[:system] = change.new.to_i == 1 ? true : change.old.to_i == 1 ? false : nil + alert("Invalid system setting for depot #{depot[:id]} found at #{branch_block.path}") if depot[:system].nil? + when /sharedinstall/i + depot[:shared] = change.new.to_i == 1 ? true : change.old.to_i == 1 ? false : nil + alert("Invalid shared setting for depot #{depot[:id]} found at #{branch_block.path}") if depot[:shared].nil? + else + unknown_keys << change.key + end + next + end + + # Ignore changes that are neither depot changes or branch changes + branch_name = branch_block.at('.branch-name') + if !branch_name + counters[:other_subitems] += 1 + next + end + + # The following changes correspond to a given branch + counters[:branch_subitems] += 1 + branch = branches[branch_name.content] + branch[:created] = datetime unless branch[:created] && branch[:created] < datetime + if branch_name.previous.content[/removed/i] + counters[:branch_subitems_removed] += 1 + branch[:deleted] = datetime + next # If a branch is removed, we don't parse the individual attribute removals + elsif branch_name.previous.content[/added/i] + counters[:branch_subitems_added] += 1 + else + counters[:branch_subitems_changed] += 1 + end + + official_id_edited = false + manifests_edited = false + old_official_id = current_official_id[branch[:id]] + old_manifests = current_manifests[branch[:id]].dup + + # Branch changes are listed by attribute + branch_block.at('ul.app-history').search('>li').each do |field_block| + # Reject incorrectly pre-parsed changes + counters[:branch_changes] += 1 + change = parse_change[field_block] + next unless change&.valid + counters[:branch_changes_valid] += 1 + + # Parse new value + case change.key + when /timeupdated/i # New branch timestamp + next if change.new.empty? + time = Time.parse(change.new) + branch[:created] = time unless branch[:created] && branch[:created] < time + branch[:updated] = time + when /buildid/i # New branch build ID + official_id_edited = true + if change.new.empty? + current_official_id.delete(branch[:id]) if !change.old.empty? + next + end + current_official_id[branch[:id]] = change.new.to_i + branch[:official_id] = change.new.to_i + when /pwdrequired/i # Private branches + branch[:private] = change.new.to_i == 1 ? true : change.old.to_i == 1 ? false : nil + alert("Invalid private setting for branch #{branch[:name]} found at #{field_block.path}") if branch[:private].nil? + when /description/i + branch[:description] = change.new unless change.new.empty? + when /Depot\s+(\d+)\s+encrypted(_gid_2|\s+gid)/i # Encrypted manifest ID (private branches) + manifests_edited = true + if change.new.empty? + current_manifests[branch[:id]].delete($1.to_i) if !change.old.empty? + next + end + depot = depots[$1.to_i] + gid = [change.new].pack('H*') + manifest = manifests[gid] + manifest[:depot_id] = depot[:id] + manifest[:date] = datetime unless manifest[:date] && manifest[:date] < datetime + current_manifests[branch[:id]][depot[:id]] = gid + when /encrypted/i # Other fields for private branches + # Ignore + when /Depot\s+(\d+)\s+gid/i # New depot version, and maybe new manifest + manifests_edited = true + if change.new.empty? + current_manifests[branch[:id]].delete($1.to_i) if !change.old.empty? + next + end + depot = depots[$1.to_i] + gid = change.new.to_i + manifest = manifests[gid] + manifest[:depot_id] = depot[:id] + manifest[:date] = datetime unless manifest[:date] && manifest[:date] < datetime + current_manifests[branch[:id]][depot[:id]] = gid + when /Depot\s+(\d+)\s+size/i # New depot raw size + # To be fetched later + when /Depot\s+(\d+)\s+download/i # New depot compressed size + # To be fetched later + else + unknown_keys << change.key + end + end # field + + # Detect actual manifest changes. A redundant change notably happened on 30 May 2023. + # The alert should logically never trigger, it's there for sanity. + current_manifests[branch[:id]] = current_manifests[branch[:id]].sort_by(&:first).to_h + manifests_changed = old_manifests != current_manifests[branch[:id]] + special_sets[:redundant_manifests] << datetime if manifests_edited && !manifests_changed + alert("Manifests changed without being edited") if !manifests_edited && manifests_changed + + # Detect actual build ID changes. + official_id_changed = old_official_id != current_official_id[branch[:id]] + special_sets[:redundant_official_ids] << datetime if official_id_edited && !official_id_changed + alert("Build ID changed without being edited") if !official_id_edited && official_id_changed + + # If branch build changed (either ID or manifests): + # - Both ID and manifest change: Regular build. + # - ID change without manifest changes: Repoint branch to preexisting build, or just patch metadata. + # - Manifest changes without ID change: Transitional build. + if official_id_edited || manifests_edited + counters[:builds] += 1 + + # Create new build or fetch preexisting one. + key = SteamBuild._hash(current_manifests[branch[:id]]) + is_new_build = !builds.key?(key) + is_new_id = versions.none?{ |h| h[:official_id] == current_official_id[branch[:id]] } + build = builds[key] + + # If build is new, track contents (depot -> manifest mapping) + if is_new_build + current_manifests[branch[:id]].sort_by(&:first).each{ |d, m| + contents << { build_id: build[:id], depot_id: d, manifest_id: manifests[m][:id] } + } + end + + # Track new branch version + if official_id_changed || manifests_changed + id = current_official_id[branch[:id]] + special_sets[:rollbacks] << id if id.to_i < old_official_id.to_i + versions << { + branch_id: branch[:id], + build_id: build[:id], + official_id: id, + date: datetime + } + end + + # Stats and logs + if official_id_edited && manifests_edited + counters[:builds_regular] += 1 + elsif !official_id_edited + special_sets[:transitional] << datetime + elsif !is_new_id + special_sets[:repointed] << current_official_id[branch[:id]] + else + special_sets[:metadata] << current_official_id[branch[:id]] + end + else + special_sets[:nonbuilds] << datetime + end + + end # branch + end # depot + end # panel + puts + + # Log results + format_slices = Proc.new{ |items, levels, width| + items.uniq.each_slice(width).map{ |slice| slice.join(', ') }.join("\n".ljust(2 * levels + 1, ' ')) + } + format_times = Proc.new{ |times, levels| + format_slices[times.map{ |time| time.strftime('%e %B %Y – %H:%M:%S').rjust(28) }, levels, 4] + } + reused_ids = versions.group_by{ |h| h[:branch_id] } + .map{ |branch_d, list| + list.map{ |h| h[:official_id] }.chunk_while{ |a, b| a == b }.map(&:first) + }.flatten.tally.select{ |_, c| c > 1 } + reused_ids_total = versions.map{ |h| h[:official_id] }.tally.select{ |_, c| c > 1 } + reused_builds = versions.map{ |h| h[:build_id] }.tally.select{ |_, c| c > 1 } + reused_builds_dif = versions.group_by{ |h| h[:build_id] }.map{ |build_id, list| + [build_id, list.map{ |h| h[:official_id] }.uniq] + }.select{ |build_id, ids| ids.size > 1 }.sort_by{ |build_id, ids| -ids.size }.to_h + dbg( + <<~LOG + Scanned the following elements: + #{counters[:panels]} panels. + #{counters[:parsed_panels]} parsed. + #{counters[:items]} items. + #{counters[:depot_items]} from depots. + #{counters[:subitems]} subitems. + #{counters[:leaf_subitems]} leaf changes. + #{counters[:depot_leafs]} depot leafs. + #{special_sets[:other_leafs].size} other leafs: + #{special_sets[:other_leafs].uniq.join(', ')}. + #{counters[:branch_subitems]} branch changes. + #{counters[:branch_subitems_added]} branch additions. + #{counters[:branch_subitems_changed]} branch modifications. + #{counters[:branch_subitems_removed]} branch removals. + #{counters[:other_subitems]} other changes. + #{counters[:branch_changes]} individual branch changes. + #{counters[:branch_changes_valid]} valid. + Build breakdown: + #{counters[:builds]} rebuilds. + #{counters[:builds_regular]} regular builds. + #{special_sets[:repointed].size} repointings: + #{special_sets[:repointed].join(', ')}. + #{special_sets[:metadata].size} metadata patches: + #{special_sets[:metadata].join(', ')}. + #{special_sets[:transitional].size} transitional builds on: + #{format_times[special_sets[:transitional], 3]}. + #{special_sets[:nonbuilds].size} nonbuilds (other changes) on: + #{format_times[special_sets[:nonbuilds], 2]}. + Interesting edge cases: + #{reused_builds.size} reused builds (#{reused_builds.values.sum} times). + #{reused_builds_dif.size} reused builds with different build IDs (#{reused_builds_dif.values.map(&:size).sum} times): + #{reused_builds_dif.sort_by(&:first).map{ |id, list| "#{id} (#{list.size})" }.join(', ')}. + #{reused_ids_total.size} reused build IDs (#{reused_ids_total.values.sum} times). + #{reused_ids.size} excluding transitional (#{reused_ids.values.sum} times): + #{format_slices[reused_ids.keys, 4, 16]}. + #{special_sets[:rollbacks].size} rollbacks: + #{special_sets[:rollbacks].join(', ')}. + #{special_sets[:redundant_manifests].size} redundant manifest changes on: + #{format_times[special_sets[:redundant_manifests], 3]}. + #{special_sets[:redundant_official_ids].size} redundant build ID changes on: + #{format_times[special_sets[:redundant_official_ids], 3]}. + Branch breakdown: + #{ + branches.map{ |id, branch| + created = branch[:created].strftime('%F') + deleted = branch[:deleted]&.strftime('%F') || ' ' * 10 + name = branch[:name].ljust(16) + content = current_manifests[id].sort_by(&:first).map{ |depot_id, manifest_gid| + m = manifests[manifest_gid] + "%d:%19.19s" % [depot_id, m[:gid] || m[:encrypted_gid].unpack1('H*').upcase] + }.join(', ') + "%s - %s | %s | %8d | %s" % [created, deleted, name, branch[:official_id], content] + }.join("\n ") + } + LOG + ) + log( + <<~LOG + Finished seeding from SteamDB history, found the following elements: + #{branches.size} branches + #{depots.size} depots + #{manifests.size} manifests + #{builds.size} builds + #{versions.size} branch versions + #{contents.size} build contents + LOG + ) + alert("Found #{unknown_keys.size} unknown keys found:\n#{unknown_keys.join("\n ")}") unless unknown_keys.empty? + + # Insert all parsed data into database + SteamBranch.insert_all(branches.values) + SteamDepot.insert_all(depots.values) + SteamManifest.insert_all(manifests.values) + SteamBuild.insert_all(builds.values) + SteamBranchVersion.insert_all(versions) + SteamBuildContent.insert_all(contents) + rescue => e + lex(e, 'Failed to seed SteamDB history') + end + +end + +# Wrapper to track RSS or Atom feeds, usually for N++-related news. Also has +# custom parsers to support other formats. +class Feed + MAX_REPORT_AGE = 24 * 60 * 60 # Oldest news to notify + DATABASE_KEY = 'feed_%s_date' + + FeedItem = Struct.new(:title, :description, :author, :link, :date, :image) + @@feeds = {} + + def self.check + @@feeds.each{ |k, v| v.check } + end + + def self.get(name) + @@feeds[name] + end + + def self.nav(name) + feed = @@feeds[name] + return unless feed + item = feed[0] + return unless item + feed.post(item, botmaster.pm) + end + + def initialize(key, uri, channel_id = nil, title: nil, type: 'rss') + @key = key + @uri = uri + @type = type + @channel = channel_id ? find_channel(id: channel_id) : botmaster.pm + @title = title || "New #{key} update" + @items = [] + @updated = nil + @@feeds[@key] = self + end + + def get_date + prop = GlobalProperty.find_or_create_by(key: DATABASE_KEY % @key) + prop.value ? Time.parse(prop.value) : Time.now - MAX_REPORT_AGE + end + + def set_date(date) + GlobalProperty.find_or_create_by(key: DATABASE_KEY % @key).update(value: [get_date, date].max) + end + + def [](i) + return unless @items.size > i + @items[i] + end + + # Default parser, should work for most standard RSS feeds + def parse_rss(data) + RSS::Parser.parse(data).items.map{ |item| + desc = HtmlToMarkdown.convert(item.description.gsub(/\n+/, "\n")).content + enc = item.enclosure + image = enc && enc.type.split('/').first == 'image' ? enc.url : nil + FeedItem.new(item.title, desc, nil, item.link, item.date, image) + } + end + + # Parse Assembla's XML bug report list + # See https://app.assembla.com/spaces/nplusplus/support/tickets + def parse_assembla(data) + doc = Nokogiri::XML(data) + doc.search('ticket').take(10).map{ |ticket| + title = ticket.at('summary').content + description = ticket.at('description').content + author = doc.search('ticket').first.at('reporter>name').content + link = ASSEMBLA_URI_TICKET % [ticket.at('number').content.to_i] + date = Time.parse(ticket.at('created-on').content) + FeedItem.new(title, description, author, link, date, nil) + } + end + + def parse(data) + send("parse_#{@type}", data) + rescue + binding.pry + [] + end + + def update + res = Net::HTTP.get_response(URI(@uri)) + return unless res.is_a?(Net::HTTPSuccess) + @updated = Time.now + @items = parse(res.body) + rescue => e + lex(e, "Failed to fetch update from #{@key} RSS feed") + end + + def check + update + date = get_date + @items.select{ |item| item.date > date }.sort_by(&:date).each{ |item| + report(item) + sleep(1) + } + end + + def format(item) + desc = item.description.dup + desc.prepend("⤷ by: __#{item.author}__\n\n") if item.author + desc.prepend("## __#{item.title}__\n") if item.title + Discordrb::Webhooks::Embed.new( + title: @title, + description: desc[...1024], + url: item.link, + timestamp: item.date, + thumbnail: item.image ? Discordrb::Webhooks::EmbedThumbnail.new(url: item.image) : nil + ) + end + + def post(item, channel) + send_message(channel, embeds: [format(item)]) + end + + def report(item) + return unless @channel + post(item, @channel) + set_date(item.date) + rescue => e + lex(e, "Failed to report #{@key} RSS news") + end + + def size + @items.size + end + + def list + list = @items.map{ |item| + link = mdurl(item.title.to_s, item.link, false) + time = format_timestamp(item.date, date: true) + "- %s by %s on %s" % [link, item.author || '?', time] + }.join("\n") + "Latest news:\n#{list}" + end +end + +# Generic wrapper around a Puma server. Used for both the N++ proxy and the API. +# See "Socket Variables" in constants.rb for docs +class Server + DEFAULT_HOST = '0.0.0.0' + DEFAULT_CACHE = 24 * 60 * 60 # Local caching time + SUPPORTED_METHODS = %w[GET POST] + + attr_reader :name, :log_req, :log_res + + # Functionality common to requests and responses + module Message + def [](name) + @headers[name.tr('_', '-').downcase] + end + + def []=(name, value) + @headers[name.to_s] = value.to_s + end + + def body=(value) + @body = value + @body = @body.to_json if @body.is_a?(Hash) + @headers['Content-Length'] = @body.bytesize.to_s + end + + def boundary + @headers['Content-Type'].to_s[/\Amultipart\/.*?boundary=\"?([^\";,]+)\"?/, 1] + end + end + + # Wrapper for a request (Rack environment) + class Request + include Message + attr_reader :method, :path, :headers, :body, :query, :parts + + def initialize(env) + req = Rack::Request.new(env) + @ip = env['REMOTE_ADDR'] + @method = env['REQUEST_METHOD'].upcase + @path = env['PATH_INFO'] + @query = req.GET.dup + @body = '' + @parts = {} + + # Parse HTTP headers manually from env because Rack's helpers suck + @headers = env.select{ |k, _| k.start_with?('HTTP_') } + .transform_keys{ |k| k.sub(/^HTTP_/, '').tr('_', '-').downcase } + @headers['content-type'] = env['CONTENT_TYPE'] if env['CONTENT_TYPE'] + @headers['content-length'] = env['CONTENT_LENGTH'] if env['CONTENT_LENGTH'] + return if @headers['content-length'].to_i <= 0 + + # Read body and parse form parts if available + @body = req.body.read + return unless req.form_data? + req.body.rewind + req.POST.each{ |k, v| + if v.is_a?(Hash) + @parts[k] = v[:tempfile].read + v[:tempfile].close + else + @parts[k] = v + end + } + end + + def query_string() Rack::Utils.build_query(@query) end + def route() @path.split('/').reject(&:empty?) end + def root() route.first.to_s end + + def key + @path + (!query_string.empty? ? '?' + query_string : '') + end + + def log(server) + lin("%s: %s %s %dB %s" % [server.name, @ip, @method, @body.bytesize, key]) + return if !Log.socket || @body.empty? || !server.log_req + filename = "#{server.name.downcase}_req_#{sanitize_filename(root)}_#{Time.now.strftime('%Y-%m-%d-%H-%M-%S-%L')}" + File.binwrite(File.join(DIR_LOGS, filename), @body) + end + + def clear + @headers.clear + @body.clear + rescue => e + # We must handle exceptions here to prevent them from leaking to Puma + lex(e, 'Failed to clear HTTP request') + end + end + + # Wrapper for a response + class Response + include Message + attr_reader :body, :headers, :cache + + def initialize(status, body = '', headers = {}, cache: DEFAULT_CACHE) + @status = status + @headers = headers + @body = body + @cache = cache + end + + def cache=(cache) + @cache = Server.normalize_cache(cache) || @cache + end + + def encode(accepted) + return if @headers.key?('Content-Encoding') + accepted.split(',').each{ |method| + case method.split(';')[0].strip + when 'gzip', 'x-gzip' + self.body = Zlib.gzip(@body) + @headers['Content-Encoding'] = 'gzip' + return true + when 'deflate' + self.body = Zlib.deflate(@body) + @headers['Content-Encoding'] = 'deflate' + return true + end + } + false + end + + def error_client(msg = 'Unable to parse request', code = 400) + @status = code + self.body = msg + end + + def error_server(msg = 'Unable to process request', code = 500) + action_inc('api_error') + @status = code + self.body = msg + end + + # Dispatch response in Rack's expected format + def dispatch + [@status, @headers, [@body]] + end + + # Fill body with arbitrary data or an arbitrary file as a response + # Cache sets the Cache-Control header but also the local caching time + def set_body(data: nil, file: nil, type: nil, name: nil, inline: true, cache: nil, binary: true) + # Data must be provided either directly or in a file + if !data && !file + return error_server() + elsif !data && file + file = File.join(Dir.pwd, file) + return error_client('File not found', 404) if !File.file?(file) + name ||= file + data = binary ? File.binread(file) : File.read(file) + end + disposition = inline ? 'inline' : 'attachment' + + # Set HTTP status, headers, and body + @status = 200 + self.body = data + @headers['Content-Type'] = type || Server::mimetype(name.to_s) + @headers['Content-Disposition'] = "#{disposition}; filename=\"#{name ? File.basename(name) : 'data.bin'}\"" + @cache = Server.normalize_cache(cache) || @cache + @headers['Cache-Control'] = @cache > 0 ? "public, max-age=#{@cache}, immutable" : 'no-cache' + end + + def log(server, time, cached = false) + delta = Time.now - time + lout("%s: %d %dB %dms %s" % [server.name, @status, @body.bytesize, 1000 * delta, cached ? '(cached)' : '']) + return if !Log.socket || @body.empty? || !server.log_res + if @body.encoding.name == 'UTF-8' + dbg('Response body: ' + @body) + else + filename = "#{server.name.downcase}_res_#{Time.now.strftime('%Y-%m-%d-%H-%M-%S-%L')}" + File.binwrite(File.join(DIR_LOGS, filename), body) + end + rescue => e + # We must handle exceptions here to prevent them from leaking to Puma + lex(e, 'Failed to log HTTP response') + end + end + + def self.mimetype(filename) + Rack::Mime.mime_type(File.extname(filename)) + end + + # Standardize cache values: + # true - 1 year + # false - no cache + # number - seconds + # else - nil (keep default) + def self.normalize_cache(cache) + case cache + when true + 365 * 24 * 60 * 60 + when false + 0 + when Numeric + [cache.round, 0].max + else + nil + end + end + + def initialize(name, port, host: DEFAULT_HOST, min_threads: 1, max_threads: 5, cache: 0, log_req: true, log_res: true) + @name = name + @host = host + @port = port + @routes = SUPPORTED_METHODS.map{ |method| [method, {}] }.to_h + @error = nil + @running = false + @cache = Cache.new(capacity: cache, duration: DEFAULT_CACHE / 60, name: name) if cache > 0 + @puma = Puma::Server.new(self, nil, min_threads: min_threads, max_threads: max_threads, Silent: true) + @puma.add_tcp_listener(@host, @port) + @log_req = log_req + @log_res = log_res + end + + def start + return if !SOCKETS || @running + @puma.run + @running = true + succ("Started #{@name} server on TCP port #{@port}") + end + + def stop(graceful = true) + return unless @running + @puma.stop(graceful) + @running = false + err("Stopped #{@name} server on TCP port #{@port}") + end + + # Dynamic method dispatcher + SUPPORTED_METHODS.each do |method| + define_method(method.downcase.to_sym) do |regex, &block| + @routes[method][regex] = block + end + end + define_method(:error) do |&block| + @error = block + end + + # Rack entry point + # TODO: Monkey-patch Rack's logging + def call(env) + time = Time.now + hit_cache = false + + # Parse request + request = Request.new(env) + response = Response.new(404) + request.log(self) + return if !SUPPORTED_METHODS.include?(request.method) || request.path =~ /\.\./i + + # Fetch request handler + handler = @routes[request.method].find{ |k, v| request.root =~ k }&.last + return unless handler + + # Generate response + if !!@cache && @cache.check(request.key) + hit_cache = true + response = Marshal.load(@cache.get(request.key)) + @cache.tap(request.key) + else + with_connection() { handler.call(request, response) } + @cache.add(request.key, Marshal.dump(response), response.cache / 60, compress: true) if !!@cache && response.cache > 0 + end + + # Encode body if suitable + encoding = request['accept-encoding'] + encoded = response.encode(encoding) if encoding + rescue => e + lex(e, "#{@name} server: Failed to handle request") + @error ? @error.call(request, response) : response.error_server + ensure + request.clear + response.log(self, time, hit_cache) + return response.dispatch + end + +end + +# Implement the N++ proxy server that handles custom leaderboards for mappacks, CUSE, etc. +class NPPServer < Server + + def initialize(**kwargs) + super('CLE', CLE_PORT, **kwargs) + + # We use the same handler for all requests + get(//) { |req, res| handle(req, res) } + post(//){ |req, res| handle(req, res) } + end + + def handle(req, res) + # Ignore invalid requests + action_inc('http_requests') + mappack, p1, p2, *route = req.route + return if route.empty? || p1 != 'prod' || p2 != 'steam' + function = route.join('/') + + # Always log players in, regardless of mappack + return respond(res, Player.login(mappack, req)) if req.method == 'POST' && function == METANET_POST_LOGIN + + # Automatically forward requests for unknown or disabled mappacks + return fwd(req, res) if !Mappack.find_by(enabled: true, code: mappack) + + # CUSE requests only affect userlevel searching + if mappack == 'cuse' + return fwd(req, res) unless req.method == 'GET' && function == METANET_GET_SEARCH + return respond(res, Userlevel.search(req)) + end + + # Parse request + body = 0 + case req.method + when 'GET' + case function + when METANET_GET_SCORES + body = MappackScore.get_scores(mappack, req.query, req) + when METANET_GET_REPLAY + body = MappackScore.get_replay(mappack, req.query, req) + when METANET_GET_SEARCH + body = Userlevel.search(req) + end + when 'POST' + case function + when METANET_POST_SCORE + body = Scheduler.with_lock do # Prevent restarts during score submission + MappackScore.add(mappack, req.parts, req) + end + when METANET_POST_LOGIN + body = Player.login(mappack, req) + end + end + + body == 0 ? fwd(req, res) : respond(res, body) + rescue => e + action_inc('http_errors') + lex(e, "CLE socket failed to parse request for: #{req.path}") + nil + end + + def respond(res, body = nil) + !body.is_a?(String) ? res.error_client : res.set_body(data: body, cache: false) + end + + def fwd(req, res) + respond(res, CLE_FORWARD ? forward(req) : nil) + end +end + +# Custom API for browser access to specific outte functions +class APIServer < Server + + def initialize(**kwargs) + super('API', API_PORT, cache: 100, **kwargs) + + # Home page + get(/^$/){ |req, res| + action_inc('api_home') + res.set_body(data: build_page('home'){ handle_home() }, name: 'index.html') + res.cache = false + } + + # Dispatch favicon + get(/^favicon.ico$/){ |req, res| + res.set_body(file: File.join(PATH_ICONS, API_FAVICON + '.ico'), cache: true) + res.cache = false + } + + # Complementary API files (HTML, CSS, JS) + get(/^api$/) { |req, res| + res.set_body(file: req.path, cache: true) + res.cache = false + } + + # Image resouces + get(/^img$/) { |req, res| + res.set_body(file: req.path, cache: true) + res.cache = false + } + + # Generate octicons on the fly + get(/^octicon$/) { |req, res| + file = req.route.last + name, size, color = file.remove('.svg').split('_') + res.set_body(data: build_octicon(name, size, color), name: file, cache: true) + res.cache = false + } + + # Show current contents of the cache + get(/^cache$/) { |req, res| + content = build_page('run', 'API cache status') { handle_cache(req.query) } + res.set_body(data: content, name: 'cache.html', cache: false) + } + + # Show inne repo status + get(/^git$/) { |req, res| + action_inc('api_git') + link = "outte++ " + body = build_page('git', "Latest changes to the #{link} repo in GitHub") { handle_git(req.query) } + res.set_body(data: body, name: 'git.html') + } + + # Show submitted scores + get(/^scores$/) { |req, res| + action_inc('api_scores') + body = build_page('scores', 'Show the latest submitted top20 highscores to vanilla leaderboards'){ handle_scores(req.query) } + res.set_body(data: body, name: 'scores.html', cache: 5 * 60) + } + + # Show detailed info about runs + get(/^run$/) { |req, res| + action_inc('api_run') + body = build_page('run', 'Show information about a given run') { handle_run(req.query) } + res.set_body(data: body, name: 'run.html', cache: 60 * 60) + } + + # Generate a screenshot with a user-supplied palette + post(/^screenshot$/){ |req, res| + ret = handle_screenshot(req.query, req.body) + if !ret.key?(:file) + res.error_client(ret[:msg] || 'Unknown query error') + elsif !ret[:file] + res.error_server('Error generating screenshot') + else + res.set_body(data: ret[:file], name: ret[:name], cache: 0) + end + } + + # Generic error handler + error() { |req, res| handle_error(res, 'Failed to process request') } + end + + # Fetch a file for the API to server + def fetch_file(file) + File.join(DIR_API, file) + end + + # Retrieves a timestamp to use for file versioning (cache busting) + def file_timestamp(file) + File.mtime(fetch_file(file)).to_i.to_s + end + + # Replace a token string in an HTML template with an actual value + def replace_token(file, token, value) + file.gsub!('TOKEN' + token, value) + end + + # Escape plain HTML text + def escape_html(str) + str.gsub('&', '&').gsub('<', '<').gsub('>', '>') + end + + # Concatenate path and query properly to form a valid URI route + def make_route(path, query = {}, add: {}, remove: [], off: nil, rev: nil, page: nil) + query = query.merge(add.stringify_keys).except('off', 'rev', 'p', *remove.map(&:to_s)) + query['p'] = page.to_i if page + query['off'] = off.to_i if off + query['rev'] = rev ? 1 : 0 if !rev.nil? + return path if query.empty? + query = query.map{ |k, v| "#{k}=#{v}" }.join('&') + "#{path}?#{query}" + end + + # Build home page + def handle_home + '' + end + + # Template for all public API webpages + def build_page(title, desc = nil, &block) + # Build content + time = Time.now + content = yield + content.prepend("#{create_minitable(title, desc, time)}\n
\n") if desc + + # Edit tokens into template + html = File.read(fetch_file(API_TEMPLATE)) + replace_token(html, 'CSS', file_timestamp(API_STYLE)) + replace_token(html, 'JS', file_timestamp(API_SCRIPT)) + replace_token(html, 'TITLE', title) + replace_token(html, 'CONTENT', content) + html + end + + # Builds the mini table at the top + def build_minitable(fields) + rows = fields.map{ |k, v| + "#{k}:#{v}" + }.join("\n") + "\n#{rows}\n
" + end + + # Fills the minitable fields + def create_minitable(name, desc, time) + build_minitable({ + 'Description' => desc, + 'Request time' => Time.now.strftime('%F %T %Z'), + 'Processing time' => "%.2f ms" % [1000 * (Time.now - time)] + }) + end + + # Build navigation bar for browsing lists or tables + def build_navbar(route, params, first: nil, last: nil, page: nil, pages: nil, size: nil) + if page # Page-based navigation + [ + page > 1 ? %{Previous} : nil, + page > 1 ? %{Previous} : nil, + size ? "#{(page - 1) * size + 1} - #{page * size}" : "#{page} / #{pages}", + page < pages ? %{Next} : nil, + page < pages ? %{Last} : nil + ].compact.join("\n") + else # Offset-based navigation + [ + %{Previous}, + first ? %{Previous} : nil, + last ? %{Next} : nil, + %{Last} + ].compact.join("\n") + end + end + + # Return list of latest highscores + def handle_scores(params) + time = Time.now + + # Sanitize parameters + allowed = ['off', 'rev', 'id', 'player', 'type', 'tab'] + params.reject!{ |k, v| !allowed.include?(k) } + if params['off'] + if is_num(params['off']) + params['off'] = params['off'].to_i.clamp(0, 2 ** 31 - 1).to_s + else + params.delete('off') + end + end + params.delete('type') if params['type'] && !(0..3).cover?(params['type'].to_i) + params.delete('tab') if params['tab'] && !(0..6).cover?(params['tab'].to_i) + params.delete('id') if !params['type'] + + # Parse query parameters + offset = params['off'].to_i if params['off'] + rev = params['rev'] == '1' + type = TYPES.find{ |k, v| v[:id] == params['type'].to_i }.last if params['type'] && params['type'].to_i != 3 + ul = params['type'].to_i == 3 + id = params['id'].to_i if params['id'] + player = params['player'].to_i if params['player'] + tab = params['tab'].to_i if params['tab'] + + # Table fields + f_includeVanilla = 0b0001 + f_pluckVanilla = 0b0010 + f_includeUser = 0b0100 + f_pluckUser = 0b1000 + fields = { + id: { flags: 0b1111, th: 'Index', tt: 'Score ID in outte\'s db' }, + replay_id: { flags: 0b1111, th: 'ID', tt: 'Score ID in Metanet\'s db' }, + metanet_id: { flags: 0b1111, th: 'Player ID', tt: 'Player ID in Metanet\'s db' }, + player_name: { flags: 0b0101, th: 'Player name', tt: 'Latest recorded name' }, + highscoreable_type: { flags: 0b0111, th: 'Board type', tt: 'Level / Episode / Story / Userlevel' }, + highscoreable_id: { flags: 0b0011, th: 'Board ID', tt: 'Internal ID' }, + userlevel_id: { flags: 0b1100, th: 'Board ID', tt: 'Userlevel ID' }, + level_name: { flags: 0b0101, th: 'Board', tt: 'Standard ID' }, + rank: { flags: 0b1101, th: 'Rank', tt: 'Rank for non-obsolete highscores' }, + score: { flags: 0b1111, th: 'Score', tt: 'Score in seconds' }, + framecount: { flags: 0b0011, th: 'Frames', tt: 'Run length / framecount' }, + gold: { flags: 0b0011, th: 'Gold', tt: 'Pieces of gold collected' }, + date: { flags: 0b0011, th: 'Date', tt: 'Date of archival' }, + expired: { flags: 0b0011, th: 'O', tt: 'Obsolete run' }, + cheated: { flags: 0b0011, th: 'C', tt: 'Cheated run' } + } + header = fields.select{ |k, v| v[:flags] & (ul ? f_includeUser : f_includeVanilla) > 0 } + .map{ |k, v| "#{v[:th]}" } + .join("\n") + header = "#{header}" + + # Fetch scores + size = 50 + if !ul + list = Archive.where(offset ? "id #{rev ? '>' : '<'} #{offset}" : '' ) + .where(type ? { highscoreable_type: type[:name] } : nil) + .where(id ? { highscoreable_id: id } : nil) + .where(player ? { metanet_id: player } : nil) + .where(tab ? { tab: tab } : nil) + else + list = UserlevelScore.where(offset ? "replay_id #{rev ? '>' : '<'} #{offset}" : '' ) + .where(id ? { userlevel_id: id } : nil) + .where(player ? { metanet_id: player } : nil) + end + to_pluck = fields.select{ |k, v| v[:flags] & (ul ? f_pluckUser : f_pluckVanilla) > 0 }.keys + list = list.order(ul ? { replay_id: rev ? :asc : :desc } : { date: rev ? :asc : :desc }).limit(size).pluck(*to_pluck) + list.reverse! if rev + list = list.map{ |s| to_pluck.zip(s).to_h } + + # Fetch fields from other tables (player names, level names...) + id_key = ul ? :userlevel_id : :highscoreable_id + ids = list.map{ |s| s[:id] } + metanet_ids = list.map{ |s| s[:metanet_id] } + replay_ids = list.map{ |s| s[:replay_id] } + level_ids = list.map{ |s| s[id_key] } + if !ul + pnames = Player.where(metanet_id: metanet_ids).pluck(:metanet_id, :name).to_h + lnames = [ + Level.where(id: level_ids).pluck(:id, :name).to_h, + Episode.where(id: level_ids).pluck(:id, :name).to_h, + Story.where(id: level_ids).pluck(:id, :name).to_h + ] + ranks = Score.where(replay_id: replay_ids).pluck(:replay_id, :rank).to_h + else + pnames = UserlevelPlayer.where(metanet_id: metanet_ids).pluck(:metanet_id, :name).to_h + lnames = Userlevel.where(id: level_ids).pluck(:id, :title).to_h + end + + # Format table rows + yes = '
' + no = '
' + rows = list.map{ |s| + type_id = ul ? 3 : TYPES[s[:highscoreable_type]][:id] + player_uri = make_route('scores', params, add: { player: s[:metanet_id] }) + board_uri = make_route('scores', params, add: { type: type_id, id: s[id_key] }) + replay_uri = make_route('run', { type: type_id, id: s[:replay_id] }) + date = s[:date] <= Archive::EPOCH ? "Before #{Archive::EPOCH.strftime('%F')}" : s[:date].strftime('%F %T') if !ul + lname = ul ? lnames[s[id_key]] : lnames[type_id][s[id_key]] + rank = ul ? s[:rank] : ranks[s[:replay_id]] + score = '%.3f' % [s[:score] / 60.0] + score_link = ul ? score : "#{score}" + block = %{ + #{s[:id]} + #{s[:replay_id]} + #{s[:metanet_id]} + #{escape_html(pnames[s[:metanet_id]][0, 16])} + #{ul ? 'Userlevel' : s[:highscoreable_type]} + #{s[id_key]} + #{lname[0, 32]} + #{rank} + #{score_link} + } + block += %{ + #{s[:framecount]} + #{s[:gold]} + #{date} + #{s[:expired] ? yes : no} + #{s[:cheated] ? yes : no} + } if !ul + "#{block}" + }.join("\n") + + # Format table caption with filters + if !list.empty? + navbar = build_navbar('scores', params, first: ul ? replay_ids.max : ids.max, last: ul ? replay_ids.min : ids.min) + elsif params['off'] + navbar = build_navbar('scores', params) + end + types = TYPES.map{ |name, att| + #next att[:name] if type && att[:id] == type[:id] + route = make_route('scores', params, add: { type: att[:id] }, remove: [:id]) + "#{att[:name]}" + } + types.prepend("All") + types << "Userlevel" + types = types.join("\n") + + tabs = TABS_SOLO.sort_by{ |_, v| v[:index] }.map{ |_, att| + #next att[:code] if att[:tab] == tab + route = make_route('scores', params, add: { tab: att[:tab] }, remove: [:id]) + "#{att[:code]}" + } + tabs.prepend("All") + tabs = tabs.join("\n") + players = "All" + caption = %{ + +
+ + Type: #{types} | Tab: #{tabs} | Players: #{players} + + + #{navbar} + +
+ + } + + # Format table + %{ + + #{caption} + #{header} + #{rows} +
+ } + end + + # Show information about a particular run + def handle_run(params) + time = Time.now + type = TYPES.find{ |k, v| v[:id] == params['type'].to_i }.last + + # Form for manually choosing type and replay ID + default_ids = [] + options = TYPES.map{ |k, v| + default = Archive.where(highscoreable_type: v[:name]).last.replay_id + default_ids << default + selected = params['type'].to_i == v[:id] ? 'selected' : '' + "" + }.join + default_id = params['id'] ? params['id'].to_i : default_ids[type[:id]] + form = %{ +
+
+ + +
+
+ + +
+ +
+ } + err = "#{form}\n

\n
%s
" + + # Parse parameters + allowed = ['id', 'type'] + params.reject!{ |k, v| !allowed.include?(k) } + return err % '' if !params['type'] && !params['id'] + params.delete('type') if !is_num(params['type']) || !(0..2).cover?(params['type'].to_i) + params.delete('id') if !is_num(params['id']) || !(0..9999999).cover?(params['id'].to_i) + return err % 'Please specify a valid type' if !params['type'] + return err % 'Please specify a valid ID' if !params['id'] + + # Fetch run + run = Archive.find_by(highscoreable_type: type[:name], replay_id: params['id'].to_i) + return err % "Run not found in database" if !run + h = run.highscoreable + s = run.highscore + p = run.player + d = run.demo + + # Embedded binary data in Base64 (map, replay and attract files) + replay_data = d.demo if d + if h.is_level? + map_data = h.map.dump_level + if replay_data + demo_data = h.dump_demo(Demo.decode(replay_data, true)) + attract_data = [map_data.size - 8, demo_data.size, map_data[8..], demo_data].pack('L<2a*a*') + end + end + + # Links + spam_warning = "Do not spam the API to bulk download these, you will be banned by the firewall! Contact me on Discord for large dumps." + route_board = make_route('scores', { type: run.highscoreable_type, id: run.highscoreable_id }) + route_player = make_route('scores', { player: run.metanet_id }) + route_map = "data:application/octet-stream;base64,#{Base64.encode64(map_data)}" if map_data + route_replay = "data:application/octet-stream;base64,#{Base64.encode64(replay_data)}" if replay_data + route_attract = "data:application/octet-stream;base64,#{Base64.encode64(attract_data)}" if attract_data + url_board = "#{escape_html(h.name[0, 24])}" + url_player = "#{escape_html(p.name[0, 24])}" + url_map = "Map" if route_map + url_replay = "Replay" if route_replay + url_attract = "Attract" if route_attract + urls = [url_map, url_replay, url_attract].compact.join(', ') + " " + + # Run properties + attrs = [ + ['outte++ ID', "", run.id], + ['Replay ID', "", run.replay_id], + ['Player ID', "", run.metanet_id], + ['Player name', "", url_player], + ['Board type', "", run.highscoreable_type], + ['Board internal ID', "", run.highscoreable_id], + ['Board usual ID', "", url_board], + ['Board name', "", h.is_level? ? h.longname : nil], + ['Rank', "", s ? s.rank : 'No longer a highscore'], + ['Score', "", '%.3f' % [run.score / 60.0]], + ['Frame count', "", run.framecount], + ['Gold count', "", run.gold], + ['Date of archival', "", run.date], + ['Obsolete run', run.expired ? 'on' : 'off', run.expired.to_s.capitalize], + ['Cheated run', run.cheated ? 'on' : 'off', run.cheated.to_s.capitalize], + ['Download', "", urls] + ].reject{ |a, b, c| !c }.map{ |a, b, c| + cls = !b.empty? ? " class=\"#{b}\"" : '' + "#{a}:#{c}" + }.join("\n") + + prop_table = %{ + + #{attrs} +
+ } + + # Demo analysis + if !d + analysis = "
Replay not found in database
" + elsif !h.is_level? + analysis = "#{type[:name]} replays can't be analyzed yet." + else + bytes = d.decode + inputs = bytes.map{ |b| [b & 4 > 0, b & 1 > 0, b & 2 > 0] } # LJR + n = bytes.size.to_s.length + codes = ['←', '↑', '→'] + + # Condensed format + yes = '
' + no = '
' + frame = 0 + rows = bytes.chunk(&:itself).map{ |b, list| + f = [b & 4 > 0, b & 1 > 0, b & 2 > 0] + len = list.length + frame += len + [ + frame - len, + frame - 1, + len, + (f[0] ? 'L' : '') + (f[2] ? 'R' : '') + (f[1] ? 'J' : ''), + f[0] ? yes : no, + f[1] ? yes : no, + f[2] ? yes : no + ].map{ |str| "#{str}" }.join + }.map{ |row| "#{row}" }.join("\n") + analysis0 = %{ + + + #{rows} +
StartEndLengthInputLJR
+ } + + # Table format + rows = inputs.map.with_index{ |f, i| + [ + i.to_s.rjust(n, '0'), + *f.map.with_index{ |b, j| b ? codes[j] : '' } + ].map{ |str| "#{str}" }.join + }.map{ |row| "#{row}" }.join("\n") + analysis1 = %{ + + + #{rows} +
FrameLJR
+ } + + # Symbolic format + symbols = "·↑→↗←↖←↖" + rows = bytes.map{ |b| symbols[b] }.each_slice(60).with_index.map{ |row, i| + "#{(60 * i).to_s.rjust(n, '0')}#{row.join('')}" + }.join("\n") + analysis2 = %{ + + #{rows} +
+ } + + # Literal format + analysis3 = inputs.map{ |f| + (f[0] ? 'L' : '') + (f[2] ? 'R' : '') + (f[1] ? 'J' : '') + }.join(".") + + # Select menu + views = [ + 'Condensed', 'Table', 'Symbolic', 'Literal' + ].map.with_index{ |opt, i| + selected = i == 0 ? ' selected' : '' + "" + }.join("\n") + + # Analysis box + analysis = %{ +
+ View: + +
+
#{analysis0}
+ + + +
+
+ } + end + + # Final body + %{ +
+ #{form} + #{prop_table} + #{analysis} +
+ } + end + + # Latest changes to the project using GitHub's API + def handle_git(params) + # Pagination + count = 25 + page = [params['p'].to_i, 1].max + total = `git rev-list --count --all`.to_i + pag = compute_pages(total, page, count) + + # Header + header = ['Commit', 'Description', 'Author', 'Branch', 'Changes', 'Date'].map{ |th| "#{th}" }.join("\n") + + # Parse commits + raw = `git log -n #{count} --skip=#{pag[:offset]} --decorate=full --date=iso-strict --numstat --format='@@@%H|%D|%cI|%an|%ae|%cn|%ce|%s%n%B'` + commits = [] + current = nil + + raw.each_line do |line| + if line.start_with?("@@@") # New commit + commits << current if current + + # First line after sentinel (@@@) contains basic commit fields + hash, refs, date, aname, amail, cname, cmail, msg = line[3..].split("|") + + # Parse refs (branches and tags) + branches = [] + tags = [] + refs.split(",").each do |ref| + next if ref =~ /remotes/i + ref.sub!(/HEAD ->/, '') + ref.strip! + case ref + when /^refs\/heads\// + branches << ref.sub('refs/heads/', '') + when /^refs\/tags\// + tags << ref.sub('refs/tags/', '') + end + end + + # New commit block + current = { + hash: hash, + date: Time.parse(date), + author: { + name: aname, + email: amail + }, + committer: { + name: cname, + email: cmail + }, + description: msg, + extended: '', + files: {}, + changes: 0, + additions: 0, + deletions: 0, + branches: branches, + tags: tags + } + elsif line =~ /^(\d+|-)\s+(\d+|-)\s+(.+)$/ # File changes + add, del, name = $1.to_i, $2.to_i, $3 + current[:changes] += 1 + current[:additions] += add + current[:deletions] += del + current[:files][name] = [add, del] + elsif line.strip.empty? # Empty line + # Ignore + else # Remainder of description + current[:extended] << line + end + end + + # Add final commit + commits << current if current + + # Rows + rows = commits.map{ |comm| + commit = "#{comm[:hash][0, 7]}" + author = "#{comm[:author][:name]}" + branches = "master" + extended = comm[:extended].split("\n")[1..].join("\n") + extended = 'No additional info' if extended.squish.empty? + files = comm[:files].map{ |name, (add, del)| "#{name} (+#{add}, -#{del})" }.join("\n") + %{ + + #{commit} + #{comm[:description][0, 128]} + #{author} + #{branches} + #{comm[:changes]} (+#{comm[:additions]}, -#{comm[:deletions]}) + #{format_timespan(Time.now - comm[:date], 2)} ago + + } + }.join("\n") + + # Format table + %{ + + + #{header} + #{rows} +
+ #{build_navbar('git', params, page: pag[:page], pages: pag[:pages], size: count)} +
+ } + end + + # Show current API cache status + def handle_cache(params) + # Pagination + count = 25 + page = [params['p'].to_i, 1].max + total = @cache.length + pag = compute_pages(total, page, count) + now = Time.now + + # Header + header = ['Index', 'Key', 'Size', 'Raw', 'Duration', 'Left', 'Hits', 'Added', 'Accessed', 'Expires'].map{ |h| "#{h}" }.join + + # Rows + rows = @cache.entries.sort_by{ |key, entry| entry.expiry.to_f }[pag[:offset], count] + .map.with_index{ |(key, entry), i| + %{ + + #{pag[:offset] + i} + #{key} + #{entry.size} + #{entry.raw_size} + #{entry.duration} + #{(entry.expiry - now).to_i} + #{entry.taps} + #{entry.added.strftime('%F %T')} + #{entry.accessed.strftime('%F %T')} + #{entry.expiry.strftime('%F %T')} + + } + }.join("\n") + + # Format table + size = '%.1fKiB' % [ @cache.size.to_f / 1024] + raw_size = '%.1fKiB' % [ @cache.raw_size.to_f / 1024] + capacity = '%.1fMiB' % [ @cache.capacity.to_f / 1024 ** 2] + ratio = '%.1f%%' % [100 * @cache.size.to_f / @cache.capacity] + comp_ratio = '%.1f%%' % [100 * @cache.size / @cache.raw_size] unless @cache.raw_size == 0 + %{ + + + #{header} + #{rows} +
+
+ + #{@cache.name} (#{total} cur, #{@cache.adds} tot, #{@cache.taps} hits) | + #{size} / #{capacity} (#{ratio}) | + #{raw_size} -> #{size} (#{comp_ratio || 'NaN'}) + + + #{build_navbar('cache', params, page: pag[:page], pages: pag[:pages], size: count)} + +
+
+ } + end + + def handle_screenshot(params, payload) + # Parse highscoreable + h = nil + [Level, Episode, Story, Userlevel].each{ |type| + id = params["#{type.to_s.downcase}_id"] + next if !id + ul = type == Userlevel + key = ul ? :id : :name + h = type.find_by(key => id) + h = type.mappack.find_by(key => id) if !h && !ul + return { msg: "#{type} #{id} not found" } if !h + } + return { msg: 'Highscoreable must be supplied via "level_id", "episode_id", "story_id" or "userlevel_id"' } if !h + + # Parse palette + if payload && !payload.empty? + changed = Map.change_custom_palette(payload) + return { msg: 'Provided palette data is corrupt' } if !changed + palette = 'custom' + elsif params['palette'] + palette_idx = Palette::NAMES.index(params['palette']) + return { msg: "Palette #{params['palette']} doesn't exist" } if !palette_idx + palette = Palette::NAMES[palette_idx] + else + palette = 'vasquez' + end + + # Generate screenshot + filename = h.is_userlevel? ? h.id.to_s : h.name + { file: Map.screenshot(palette, h: h), name: filename + '.png' } + rescue => e + lex(e, 'Failed to handle API screenshot request') + { file: nil } + end + + def handle_error(res, msg, code = 500) + body = "
#{msg}
" + res.error_server(build_page('error'){ body }.b) + rescue => e + lex(e, 'Failed to handle API error') + end +end + +# Done loading file +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) diff --git a/src/constants.rb b/src/constants.rb new file mode 100644 index 00000000..3f565567 --- /dev/null +++ b/src/constants.rb @@ -0,0 +1,1113 @@ +# This file contains most of the variables that configure the behaviour of many +# features and aspects of the bot. +# +# When first setting up the bot it may be necessary, or at least recommended, +# to configure a few of these (e.g., BOTMASTER_ID, SERVER_ID, CHANNEL_HIGHSCORES, +# DATABASE...). Several others are useful to change during development as well +# (TEST, BENCHMARK, DO_NOTHING, DO_EVERYTHING...). + +# <----------------------------------------------------------------------------> +# <------ DEVELOPMENT VARIABLES ------> +# <----------------------------------------------------------------------------> + +# General +TEST = true # Switch to the local test bot +BENCHMARK = false # Benchmark and log functions (for optimization) +DO_NOTHING = false # Don't execute any threads (see below for ind flags) +DO_EVERYTHING = false # Execute all threads +RESPOND = true # Respond to pings / DMs (for testing) +DEBUG = false # Breakpoint right after loading the bot + +# Test specific features +TEST_LOTD = false # Post lotd on startup +TEST_CTP_LOTD = false # Post CTP lotd on startup +TEST_UL_REPORT = false # Post userlevel report on startup +TEST_SCORES = false # Download all scores on startup +BENCH_IMAGES = false # Benchmark image manipulation steps + +# Internal +WAIT = 1 # Seconds between iterations of infinite loops +DATABASE = 'outte' # Database environment +DAEMON = false # Don't run Discord bot, disables active interaction + +# <----------------------------------------------------------------------------> +# <------ RESOURCE MONITORING ------> +# <----------------------------------------------------------------------------> + +# Memory +MEMORY_MONITOR = true # Regularly monitor memory usage +MEMORY_LIMIT = 0.25 # Available memory (ratio) before restarting +MEMORY_USAGE = 0.5 # Memory usage by outte (ratio) before restarting +MEMORY_CRITICAL = 0.05 # Critical memory available ratio +MEMORY_DELAY = 5 # Seconds between memory checks during monitoring + +# SQL threads +SQL_MONITOR = true # Regularly monitor available MySQL threads +SQL_DELAY = 60 # Seconds between checks +SQL_LIMIT = 0.75 # Max ratio between open connections and max connections + +# Rails pool +POOL_LIMIT = 0.9 # Ratio of used up connections before warning + +# Linux timeout utility exit codes (any other exit code is preserved) +TIMEOUT_CODE_TIMEDOUT = 124 # Process was timed out before finishing (SIGTERM) +TIMEOUT_CODE_ERROR = 125 # timeout itself failed +TIMEOUT_CODE_INVOKE = 126 # The command couldn't be invoked +TIMEOUT_CODE_NOTFOUND = 127 # The command couldn't be found +TIMEOUT_CODE_KILLED = 137 # The process was killed before finishing (SIGKILL) + +# <----------------------------------------------------------------------------> +# <------ NETWORK VARIABLES ------> +# <----------------------------------------------------------------------------> + +OFFLINE_MODE = false # Disables most intensive online functionalities +OFFLINE_STRICT = false # Disables all online functionalities of outte +RETRIES = 50 # Redownload attempts for boards / demos +ATTEMPT_LIMIT = 5 # Redownload attempts in general (bigger files) + +# Metanet's N++ server endpoints +METANET_HOST = 'dojo.nplusplus.ninja' +METANET_PATH = '/prod/steam' +METANET_GET_SCORES = 'get_scores' +METANET_GET_REPLAY = 'get_replay' +METANET_GET_LEVELS = 'query_levels' +METANET_GET_SEARCH = 'search/levels' +METANET_POST_LOGIN = 'login' +METANET_POST_SCORE = 'submit_score' +METANET_POST_LEVEL = 'submit_level' +METANET_INVALID_RES = '-1337' # N++'s server response when Steam ID is inactive + +FAST_MANUAL = true # Only use active Steam IDs for manual queries, for speed +FAST_PERIOD = 7 # Days old until a Steam ID is marked as inactive + +UPDATE_SCORES_ON_LOTD = false # Update scores right before lotd (may delay post) + +# Github +GITHUB_USER = 'edelkas' +GITHUB_REPO = 'inne' +GITHUB_BRANCH = 'master' + +# Google +GOOGLE_URL_SHEETS = 'https://docs.google.com/spreadsheets/d/%s/%s' # id, action +GOOGLE_API_SHEETS = 'https://sheets.googleapis.com/v4/spreadsheets/%s' # id +SHEET_ID_VIDEOS = '12Os50amv9nSHR3u5sDqKiwDk1eRUY5lYLzyLgEUsldQ' # N++ Video Library + +# <----------------------------------------------------------------------------> +# <------ DISCORD VARIABLES ------> +# <----------------------------------------------------------------------------> + +# IDs +BOTMASTER_ID = 204332624288677890 # User ID of the bot manager (Eddy) +SERVER_ID = 197765375503368192 # Discord server/guild ID (N++ Server) +TEST_SERVER_ID = 535635802386857995 # Development server +CHANNEL_MAIN = 197765375503368192 # #nplusplus +CHANNEL_HIGHSCORES = 210778111594332181 # #highscores +CHANNEL_SPEEDRUNNING = 231135753059565568 # #speedrunning +CHANNEL_USERLEVELS = 221721273405800458 # #userlevels +CHANNEL_NV2 = 197774025844457472 # #nv2 +CHANNEL_CONTENT = 197793786389200896 # #content-creation +CHANNEL_SECRETS = 217283494664077312 # #secrets +CHANNEL_CTP_HIGHSCORES = 1137794057205198848 # #ctp-highscores +CHANNEL_CTP_SECRETS = 1137794113475969034 # #ctp-secrets +CHANNEL_SUPPORT = 218819289266913281 # #support +CHANNEL_DEBUG = 530162144843530240 # #debug-room + +CHANNELS_CHALLENGES = [CHANNEL_SECRETS, CHANNEL_CTP_SECRETS] # Where posting challenges is allowed + +# Limits +DISCORD_CHAR_LIMIT = 2000 # Message character limit +DISCORD_FILE_LIMIT = 10 * 1000 ** 2 # Attachment size limit +DISCORD_EMBED_LIMIT = 10 # Embed count limit +DELETE_TIMELIMIT = 5 * 60 # Seconds to delete an outte post +CONFIRM_TIMELIMIT = 30 # Seconds to confirm a dialog +DISCORD_COMPONENT_LIMIT = 40 # V2 components per message +DISCORD_OPTIONS_LIMIT = 25 # Options per select menu + +# Enabled optional features +RICH_RANKINGS = false +RICH_DIFFS = true +SHOW_CHEATERS = true +SCORE_THUMBNAILS = true +SCORE_EMBEDS = false + +# Non-standard character widths in the monospaced font (for padding adjustments) +WIDTH_EMOJI = 2 +WIDTH_KANJI = 1.67 + +# Despite the bot being public, so that the botmaster does not need to be a mod +# of the server, we only allow select servers. Otherwise randos could add outte. +SERVER_WHITELIST = [SERVER_ID, TEST_SERVER_ID] + +# Application commands +SUPPORTED_COMMANDS = [:browse, :config, :download, :screenshot, :video] +DISABLED_COMMANDS = [] + +# Emoji stuff +EMOJIS_TO_DELETE = ['❌', '✖️', '🇽', '⛔', '🚫', '❎'] +EMOJIS_FOR_PLAY = ['Ninja', 'ninjajump', 'ninjavictory', 'nAight'] +EMOJI_NUMBERS = ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟'] +EMOJI_TO_BAN = '🔨' + +# Emojis available to outte. This list is OUTDATED now that discordrb supports +# application emojis natively and we don't have to do this manually. +APP_EMOJIS = { + 'plus_1st' => { test: 1393773038163460259, prod: 1393770276659138630 }, + 'plus_2nd' => { test: 1393773051748945960, prod: 1393770299454918676 }, + 'plus_3rd' => { test: 1393773065330233354, prod: 1393770312121716736 }, + 'plus_4th' => { test: 1393773080433786920, prod: 1393770327468802048 }, + 'plat_PC' => { test: 1394801514148659280, prod: 1394807830397059203 }, + 'plat_PS' => { test: 1394801418430320750, prod: 1394807791952330792 }, + 'plat_Xbox' => { test: 1394801570327040032, prod: 1394807873707446433 }, + 'plat_Switch' => { test: 1394801691630501978, prod: 1394807906897105017 }, + 'plat_Kong' => { test: 1394802850055458937, prod: 1394807753209282601 }, + 'plat_DS' => { test: 1395099420776730794, prod: 1395099616847990866 }, + 'plat_2DS' => { test: 1395101713257468044, prod: 1395108150339829760 }, + 'plat_3DS' => { test: 1395105604770467900, prod: 1395108220414201946 }, + 'plat_PSP' => { test: 1395104831177494568, prod: 1395108189095202897 }, + 'trophy_1st' => { test: 1395536394335551498, prod: 1395536824574677084 }, + 'trophy_2nd' => { test: 1395536416347259020, prod: 1395536836440625212 }, + 'trophy_3rd' => { test: 1395536430260027613, prod: 1395536847219724399 }, + 'gold_1st' => { test: 1395536441802756258, prod: 1395536858037092373 }, + 'gold_2nd' => { test: 1395536452770726049, prod: 1395536866891010209 }, + 'gold_3rd' => { test: 1395536467664703611, prod: 1395536876923916471 } +} + +# Others +BOT_STATUS = 'online' # Discord status for the bot +BOT_ACTIVITY = "inne's evil cousin" # Discord activity for the bot +DAYS_PER_EPISODE = 1 # Number of days to auto-spoiler screenshots in a mappack + +# <----------------------------------------------------------------------------> +# <------ LOGGING VARIABLES ------> +# <----------------------------------------------------------------------------> + +# General +LOG_TO_CONSOLE = true # Log stuff to the terminal +LOG_TO_FILE = true # Export logs to a file +LOG_TO_DISCORD = true # Log select stuff to the botmaster's Discord DMs +LOG_SQL = false # Log _all_ SQL queries +LOG_SQL_TO_FILE = false # Log SQL queries to the logfile +LOG_REPORT = true # Export new weekly scores to a file +LOG_FILE_MAX = 10 * 1024 ** 2 # Max log file size (10 MB) +LOG_SHELL = false # Redirect STDOUT/STDERR to outte when we call the shell + +# Log format (can be set on the fly as well) +LOG_FANCY = true # Use rich terminal logs (bold, colors...) +LOG_LEVEL_STD = :normal # Standard log level +LOG_LEVEL_TEST = :debug # Log level when testing +LOG_LEVEL_FILE = :quiet # Default file logging level (see Log class) +LOG_LEVEL = TEST ? LOG_LEVEL_TEST : LOG_LEVEL_STD # Default terminal logging level +LOG_APPS = false # Append source app to log msgs +LOG_PAD = 120 # Pad each log line to this many chars +LOG_BACKTRACES = true # Log exception backtraces + +# Log specific things +LOG_DOWNLOAD_ERRORS = false # Too spammy if no Steam IDs are active + +# <----------------------------------------------------------------------------> +# <------ PATH VARIABLES ------> +# <----------------------------------------------------------------------------> + +DIR_API = 'api' + +DIR_DB = 'db' +DIR_MIGRATION = "#{DIR_DB}/migrate" +CONFIG = "#{DIR_DB}/config.yml" +DIR_MAPPACKS = "#{DIR_DB}/mappacks" +DIR_CHALLENGES = "#{DIR_DB}/challenges" +PATH_MAPPACK_INFO = "#{DIR_MAPPACKS}/digest.json" + +DIR_IMAGES = 'img' +PATH_AVATARS = "#{DIR_IMAGES}/avatars" +PATH_EMOJI = "#{DIR_IMAGES}/emoji" +PATH_ICONS = "#{DIR_IMAGES}/icon" +PATH_PALETTES = "#{DIR_IMAGES}/palette.png" +PATH_OBJECTS = "#{DIR_IMAGES}/object_layers" +PATH_TILES = "#{DIR_IMAGES}/tile_layers" + +DIR_LIB = 'lib' + +DIR_LOGS = 'logs' +PATH_LOG_FILE = "#{DIR_LOGS}/log_outte" +PATH_LOG_SQL = "#{DIR_LOGS}/log_outte_sql" +PATH_LOG_OLD = "#{DIR_LOGS}/log_outte_old" +PATH_LOG_REPORT = "#{DIR_LOGS}/log_report" + +DIR_SOURCE = 'src' + +DIR_TEST = 'test' + +DIR_UTILS = 'util' +DIR_FONTS = "#{DIR_UTILS}/fonts" +PATH_NTRACE = "#{DIR_UTILS}/ntrace/ntrace.py" +PATH_STEAM_AUTH = "#{DIR_UTILS}/auth.py" +PATH_STEAM_KEY = "#{DIR_UTILS}/steam.pem" + +# Mappack directory tree +DIR_MAPPACK_LEVELS = "Levels" +DIR_MAPPACK_PALETTES = "Palettes" +FILENAME_MAPPACK_AUTHORS = 'AUTHORS' +FILENAME_MAPPACK_SCORES = 'SCORES' + +FILENAME_LIMIT = 128 # Never surpass 259 (Windows' limit, google MAX_PATH) + +# <----------------------------------------------------------------------------> +# <------ SCREENSHOT AND ANIMATION VARIABLES ------> +# <----------------------------------------------------------------------------> + +# The scale of the screenshots is measured in pixels per quarter tile. At normal +# 1080p resolution, the scale is 11 (44 pixels per tile). +SCREENSHOT_SCALE_LEVEL = 11 +SCREENSHOT_SCALE_EPISODE = 3 +SCREENSHOT_SCALE_STORY = 2 +THUMBNAIL_SCALE = 2 + +# Animation playback speed +ANIMATION_STEP_NORMAL = 1 # How many frames to trace per GIF frame +ANIMATION_STEP_FAST = 3 +ANIMATION_STEP_VFAST = 6 +ANIMATION_DELAY_NORMAL = 2 # Time between GIF frames (min. 2) +ANIMATION_DELAY_SLOW = 3 +ANIMATION_DELAY_VSLOW = 5 + +# Other animation parameters +ANIMATION_SCALE = 4 # Scale of GIF +ANIMATION_RADIUS = 6 # Radius of ninja balls in pixels +ANIMATION_EXHIBIT = 100 # Showcase final frame for 1 second before looping +ANIMATION_EXHIBIT_INTER = 50 # Exhibit between levels (for multi-level animations) + +# Input display for animations +ANIMATION_DEFAULT_INPUT = false # Whether to draw input displays by default +ANIMATION_WEDGE_WIDTH = 3 # Semi-width of the wedge +ANIMATION_WEDGE_HEIGHT = 4 # Full height of the wedge +ANIMATION_WEDGE_SEP = 2 # Separation between wedge and ninja marker +ANIMATION_WEDGE_WEIGHT = 2 # Thickness of the wedge + +# Search results +IMAGESEARCH_AUTO = true # Automatically perform image search +IMAGESEARCH_SCALE = 2 # Screenshot size of image search +IMAGESEARCH_LIMIT = 10 # Max search results to render +IMAGESEARCH_COLS = 5 # Number of columns when sorting results in a grid + +# Colors +TRANSPARENT_COLOR = 0x00FF00FF + +# Fonts +FONT_SCREENSHOTS = 'sys/Sys.ttf' +FONT_TIMEBAR = 'retro' + +# Memory mitigation +ANIM_GC = false # Garbage collect periodically when generating frames +ANIM_GC_STEP = 100 # How many frames to render before running the GC +ANIM_MOVE_THRESHOLD = 1 # Minimum 1-distance (in game units) to redraw an entity +ANIM_LIMIT_SOFT = 15000 # Below this many coordinates, full anims are enabled by default +ANIM_LIMIT_HARD = 200000 # Above this limit, full anims are disabled + +# Other +ANIM_PROGRESS_UPDATE = 2 # Seconds between progress bar updates + +# <----------------------------------------------------------------------------> +# <------ NTRACE VARIABLES ------> +# <----------------------------------------------------------------------------> + +FEATURE_NTRACE = true # Enable SimVYo's ntrace tool (requires Python 3) +FEATURE_ANIMATE = true # Enable animation for traces + +NTRACE_MAP_DATA = 'map_data' +NTRACE_MAP_DATA_E = "map_data_%s" +NTRACE_INPUTS = "inputs_%s" +NTRACE_INPUTS_E = 'inputs_episode' +NTRACE_OUTPUT = 'output.bin' +NTRACE_OUTPUT_E = 'output.txt' + +# <----------------------------------------------------------------------------> +# <------ MONKEY PATCHING VARIABLES ------> +# <----------------------------------------------------------------------------> + +MONKEY_PATCH = true # Enable monkey patches globally +MONKEY_PATCH_CORE = true # Enable Kernel patches (must!) +MONKEY_PATCH_ACTIVE_RECORD = true # Enable ActiveRecord monkey patches (must!) +MONKEY_PATCH_DISCORDRB = true # Enable Discordrb monkey patches (optional) +MONKEY_PATCH_CHUNKYPNG = true # Enable ChunkyPNG monkey patches (optional) + +# <----------------------------------------------------------------------------> +# <------ FORMAT VARIABLES ------> +# <----------------------------------------------------------------------------> + +# Highscoreable ID patterns +LEVEL_PATTERN = /([SCR][ILUT]?-[ABCDEX]-[0-9][0-9]?-[0-9][0-9]?)|([?!]-[ABCDEX]-[0-9][0-9]?)|([?!]{2}-[A-Z])/i +LEVEL_PATTERN_D = /([SCR][ILUT]?)-?([ABCDEX])-?([0-9][0-9]?)-?([0-9][0-9]?)|([?!])-?([ABCDEX])-?([0-9][0-9]?)|([?!]{2})-?([A-Z])/i +LEVEL_PATTERN_M = /([A-Z0-9]{3}-[SCR][ILUT]?-[ABCDEX]-[0-9][0-9]?-[0-9][0-9]?)|([A-Z0-9]{3}-[?!]-[ABCDEX]-[0-9][0-9]?)|([A-Z0-9]{3}-[?!]{2}-[A-Z])/i +LEVEL_PATTERN_M_D = /([A-Z0-9]{3})-?([SCR][ILUT]?)-?([ABCDEX])-?([0-9][0-9]?)-?([0-9][0-9]?)|([A-Z0-9]{3})-?([?!])-?([ABCDEX])-?([0-9][0-9]?)|([A-Z0-9]{3})-?([?!]{2})-?([A-Z])/i +EPISODE_PATTERN = /([SCR][ILUT]?-[ABCDEX]-[0-9][0-9]?)/i +EPISODE_PATTERN_D = /([SCR][ILUT]?)-?([ABCDEX])-?([0-9][0-9]?)/i +EPISODE_PATTERN_M = /([A-Z0-9]{3}-[SCR][ILUT]?-[ABCDEX]-[0-9][0-9]?)/i +EPISODE_PATTERN_M_D = /([A-Z0-9]{3})-?([SCR][ILUT]?)-?([ABCDEX])-?([0-9][0-9]?)/i +STORY_PATTERN = /([SCR][ILUT]?)-?([0-9][0-9]?)/i +STORY_PATTERN_M = /([A-Z0-9]{3})-?([SCR][ILUT]?)-?([0-9][0-9]?)/i + +# Organize all possible highscoreable ID patterns into a hash +# (note dashes are irrelevant for stories, as there can be no ambiguity) +ID_PATTERNS = { + 'Level' => { + vanilla: { dashed: LEVEL_PATTERN, dashless: LEVEL_PATTERN_D }, + mappack: { dashed: LEVEL_PATTERN_M, dashless: LEVEL_PATTERN_M_D }, + }, + 'Episode' => { + vanilla: { dashed: EPISODE_PATTERN, dashless: EPISODE_PATTERN_D }, + mappack: { dashed: EPISODE_PATTERN_M, dashless: EPISODE_PATTERN_M_D }, + }, + 'Story' => { + vanilla: { dashed: STORY_PATTERN, dashless: STORY_PATTERN }, + mappack: { dashed: STORY_PATTERN_M, dashless: STORY_PATTERN_M }, + } +} + +# Input +NAME_PATTERN = /(for|of) (.*)[\.\?]?/i +MAX_ENTRIES = 20 # maximum number of entries on methods with user input, to avoid spam +NV14_USERLEVEL_PATTERN = /\$(.*?)#(.*?)#(.*?)#(.+?)(?=#|\$|$)/m +NV14_MAP_PATTERN = /([^#\$\s`]+?)\|([^#\$]+?)(?=#|\$|\s|`|$)/ + +# Output +NUM_ENTRIES = 20 # number of entries to show on most methods +SCORE_PADDING = 0 # fixed padding, 0 for no fixed padding +DEFAULT_PADDING = 15 # default variable padding, never make 0 +MAX_PADDING = 15 # max variable padding, 0 for no maximum +MAX_PAD_GEN = 80 # max padding for general strings (not player names) +TRUNCATE_NAME = true # truncate name when it exceeds the maximum padding + +# Dates +DATE_FORMAT_NPP = "%Y-%m-%d-%H:%M" # Date format used by N++ +DATE_FORMAT_OUTTE = "%Y/%m/%d %H:%M" # Date format used by outte +DATE_FORMAT_MYSQL = "%Y-%m-%d %H:%M:%S" # Date format required by MySQL +DATE_FORMAT_LOG = "%Y/%m/%d %H:%M:%S.%L" # Date format used for terminal logs + +# <----------------------------------------------------------------------------> +# <------ USERLEVEL VARIABLES ------> +# <----------------------------------------------------------------------------> + +MIN_U_SCORES = 20 # Minimum number of userlevel highscores to appear in average rankings +MIN_G_SCORES = 500 # Minimum number of userlevel highscores to appear in global average rankings +PAGE_SIZE = 10 # Number of userlevels to show when browsing +PART_SIZE = 500 # Number of userlevels per file returned by the server when querying levels +MIN_ID = 22715 # ID of the very first userlevel, to exclude Metanet levels +DEFAULT_GLOBAL = true # Default to all userlevels in highscoring functions +MIN_0_SCORES = 18 # Required number of highscores to submit a zero score with outte + +# Offsets and sizes of the different binary formats used for levels +SIZE_QUERY_HEADER_GLOBAL = 48 # Size of the initial header of a userlevel query +SIZE_QUERY_HEADER_MAP = 44 # Size of each individual map header in a query + +# Userlevel query types +QT_SI = 0 # (Metanet only) +QT_S = 1 # (Metanet only) +QT_SL = 2 # (Metanet only) +QT_SS = 3 # (Metanet only) +QT_SU = 4 # (Metanet only) +QT_SS2 = 5 # (Metanet only) +QT_DLC = 6 # (Metanet only) +QT_BEST = 7 +QT_FEATURED = 8 +QT_TOP_WEEKLY = 9 +QT_NEWEST = 10 +QT_HARDEST = 11 +QT_MINE_BY_FAVS = 12 +QT_MINE_BY_DATE = 13 +QT_FAVED_BY_DATE = 14 +QT_FAVED_BY_FAVS = 15 +QT_POPULAR = 16 # (Not used) +QT_PLAYED_RECENTLY = 17 # (Not used) +QT_MADE_BY_FRIENDS_BY_DATE = 18 +QT_MADE_BY_FRIENDS_BY_FAVS = 19 +QT_PLAYED_BY_FRIENDS = 20 # (Not used) +QT_FAVED_BY_FRIENDS = 21 +QT_TRACKED_BY_FRIENDS_BY_DATE = 22 +QT_TRACKED_BY_FRIENDS_BY_RANK = 23 +QT_TRACKED_BY_FRIENDS_BY_RANK_SCORED = 24 +QT_TRACKED_BY_FRIENDS_BY_RANK_UNSCORED = 25 +QT_TRACKED_BY_DATE = 26 +QT_TRACKED_BY_RANK = 27 # (Not used) +QT_TRACKED_BY_RANK_SCORED = 28 # (Not used) +QT_TRACKED_BY_RANK_UNSCORED = 29 # (Not used) +QT_FOLLOWING_BY_DATE = 30 +QT_FOLLOWING_BY_FAVS = 31 +QT_SEARCH_BY_AUTHOR_1 = 32 # (Not used) +QT_SEARCH_BY_AUTHOR_2 = 33 # (Not used) +QT_SEARCH_BY_AUTHOR_3 = 34 # (Not used) +QT_SEARCH_BY_AUTHOR_4 = 35 # (Not used) +QT_SEARCH_BY_TITLE = 36 +QT_UNSET = 37 + +# Mapping for each QT we care about +# 'name' - Internal name used to identify each tab. +# 'fullname' - Display name of tab +# 'update' - Determines whether we update our db's tab info. +# 'size' - Determines how many maps from each tab to update. +USERLEVEL_TABS = { + QT_NEWEST => { name: 'all', fullname: 'All', size: -1, update: false }, # keep first + QT_BEST => { name: 'best', fullname: 'Best', size: 1000, update: true }, + QT_FEATURED => { name: 'featured', fullname: 'Featured', size: -1, update: true }, + QT_TOP_WEEKLY => { name: 'top', fullname: 'Top Weekly', size: 1000, update: true }, + QT_HARDEST => { name: 'hardest', fullname: 'Hardest', size: 1000, update: true } +} + +USERLEVEL_REPORT_SIZE = 500 # Number of userlevels to include in daily rankings +INVALID_NAMES = [nil, "null", ""] # Names that correspond to invalid players + +NPP_CACHE_DURATION = 5 # N++'s cache duration in seconds for userlevel queries +OUTTE_CACHE_DURATION = 24 * 60 * 60 # Ditto, but the one outte uses in its database +OUTTE_CACHE_LIMIT = 1024 # Max entries in outte's userlevel cache + +# <----------------------------------------------------------------------------> +# <------ JOKE VARIABLES ------> +# <----------------------------------------------------------------------------> + +POTATO = true # joke they have in the nv2 channel +POTATO_RATE = 1 # seconds between potato checks +POTATO_FREQ = 3 * 60 * 60 # 3 hours between potato delivers +MISHU = true # MishNUB joke +MISHU_COOLDOWN = 30 * 60 # MishNUB cooldown +COOL = true # Emoji for CKC in leaderboards +FOOD = [ # Emojis for the potato joke + ':potato:', + ':tomato:', + ':eggplant:', + ':peach:', + ':carrot:', + ':pineapple:', + ':cucumber:', + ':cheese:' +] + +# <----------------------------------------------------------------------------> +# <------ TASK VARIABLES ------> +# <----------------------------------------------------------------------------> + +# Individual flags for each thread / task +UPDATE_STATUS = false # Thread to regularly update the bot's status +UPDATE_TWITCH = false # Thread to regularly look up N related Twitch streams +UPDATE_SPEEDRUN = false # Thread to regularly look up new N related speedruns +UPDATE_RSS = false # Thread to regularly monitor several RSS feeds +UPDATE_STEAM = false # Thread to regularly fetch new N++ patches from Steam's API +UPDATE_SCORES = false # Thread to regularly download Metanet's scores +UPDATE_DEMOS = false # Thread to regularly download missing Metanet demos +UPDATE_VIDEOS = false # Thread to regularly fetch and parse the video library +POST_LOTD = false # Thread to regularly publish level of the day +POST_EOTW = false # Thread to regularly publish episode of the week +POST_COTM = false # Thread to regularly publish column of the month +POST_CTP_LOTD = false # Thread to regularly publish CTP level of the day +POST_CTP_EOTW = false # Thread to regularly publish CTP episode of the week +POST_CTP_COTM = false # Thread to regularly publish CTP column of the month +UPDATE_USERLEVELS = false # Thread to regularly download newest userlevel scores +UPDATE_USER_GLOB = false # Thread to continuously (but slowly) download all userlevel scores +UPDATE_USER_TABS = false # Thread to regularly update userlevel tabs (best, featured, top, hardest) +UPDATE_USER_INFO = false # Thread to continuously re-download userlevel data (favs, name changes, ...) +COMPUTE_FRACTIONS = false # Thread to regularly monitor new scores and compute their fractions +REPORT_METANET = false # Thread to regularly post Metanet's highscoring report +REPORT_USERLEVELS = false # Thread to regularly post userlevels' highscoring report +MAPPACK_DIGEST = false # Thread to regularly update the general mappack digest + +# Update frequencies for each task, in seconds +STATUS_UPDATE_FREQUENCY = 5 * 60 # See UPDATE_STATUS +TWITCH_UPDATE_FREQUENCY = 60 # See UPDATE_TWITCH +SPEEDRUN_UPDATE_FREQUENCY = 60 # See UPDATE_SPEEDRUN +RSS_UPDATE_FREQUENCY = 60 # See UPDATE_RSS +STEAM_UPDATE_FREQUENCY = 5 * 60 # See UPDATE_STEAM +HIGHSCORE_UPDATE_FREQUENCY = 24 * 60 * 60 # See UPDATE_SCORES +DEMO_UPDATE_FREQUENCY = 24 * 60 * 60 # See UPDATE_DEMOS +VIDEO_UPDATE_FREQUENCY = 24 * 60 * 60 # See UPDATE_VIDEOS +LEVEL_UPDATE_FREQUENCY = 24 * 60 * 60 # See POST_LOTD, POST_EOTW and POST_COTM +CTP_LEVEL_FREQUENCY = 24 * 60 * 60 # See POST_CTP_LOTD, POST_CTP_EOTW and POST_CTP_COTM +REPORT_UPDATE_SIZE = 7 * 24 * 60 * 60 # How many days to include in the report (see REPORT_METANET) +SUMMARY_UPDATE_SIZE = 1 * 24 * 60 * 60 # How many days to include in the summary (see REPORT_METANET) +USERLEVEL_SCORE_FREQUENCY = 24 * 60 * 60 # See UPDATE_USERLEVELS +USERLEVEL_UPDATE_RATE = 10 # See UPDATE_USER_GLOB +USERLEVEL_DATA_RATE = 1 * 60 * 60 # See UPDATE_USER_INFO +USERLEVEL_REPORT_FREQUENCY = 24 * 60 * 60 # See REPORT_USERLEVELS +USERLEVEL_TAB_FREQUENCY = 24 * 60 * 60 # See UPDATE_USER_TABS +USERLEVEL_DOWNLOAD_CHUNK = 100 # Userlevel batch size when updating scores globally +FRACTION_FREQUENCY = 60 # See COMPUTE_FRACTIONS +MAPPACK_DIGEST_FREQUENCY = 1 * 60 * 60 # See MAPPACK_DIGEST + +# <----------------------------------------------------------------------------> +# <------ TWITCH VARIABLES ------> +# <----------------------------------------------------------------------------> + +TWITCH_ROLE = "Voyeur" # Discord role to ping when a new stream happens +TWITCH_COOLDOWN = 2 * 60 * 60 # Cooldown to ping stream by the same user +TWITCH_BLACKLIST = [ # Don't post stream by these users + 425806947, # eblan4ikof + 1055190989, # 3awilson + 1490551040 # q101056109 +] + +# <----------------------------------------------------------------------------> +# <------ STEAMDB VARIABLES ------> +# <----------------------------------------------------------------------------> + +RSS_STEAM_URI = 'https://store.steampowered.com/feeds/news/app/%d/' # args: app_id +RSS_STEAMDB_URI = 'https://steamdb.info/api/PatchnotesRSS/?appid=%d' # args: app_id + +STEAMDB_URI_BRANCH = 'https://steamdb.info/app/%d/depots/?branch=%s' # args: app_id, branch_name +STEAMDB_URI_DEPOT = 'https://steamdb.info/depot/%d/' # args: depot_id +STEAMDB_URI_MANIFEST = 'https://steamdb.info/depot/%d/history/?changeid=M:%d' # args: depot_id, manifest_id +STEAMDB_URI_PACKAGE = 'https://steamdb.info/sub/%d/' # args: package_id +STEAMDB_URI_BUILD = 'https://steamdb.info/patchnotes/%d/' # args: build_id + +STEAMDB_COLOR_BRANCH = 0x32a852 # Bar color for branch embeds (new / deleted) +STEAMDB_COLOR_DEPOT = 0xeb4034 # Bar color for depot embeds (new / deleted) +STEAMDB_COLOR_BUILD = 0x237feb # Bar color for build embeds (new) + +ASSEMBLA_URI = 'https://app.assembla.com/spaces/cBY1XaS_Wr5y5eacwqjQXA/support/tickets/report.xml' +ASSEMBLA_URI_TICKET = 'https://app.assembla.com/spaces/nplusplus/support/tickets/%d' + +# <----------------------------------------------------------------------------> +# <------ SPEEDRUN API VARIABLES ------> +# <----------------------------------------------------------------------------> + +SPEEDRUN_DEBUG_LOGS = false # Log useful stuff to the terminal (request URI's, etc) + +SPEEDRUN_NEW_COUNT = 10 # How many speedruns to show in the most recent list +SPEEDRUN_BOARD_COUNT = 10 # How many speedruns per page in leaderboards + +SPEEDRUN_COLOR_NEW = 0xf6c42f # Embed bar color for new submitted speedruns +SPEEDRUN_COLOR_REJ = 0xf24e43 # Embed bar color for new rejected speedruns +SPEEDRUN_COLOR_VER = 0x00cb74 # Embed bar color for new verified speedruns +SPEEDRUN_COLOR_INFO = 0x93a5a6 # Embed bar color for informational notices + +# <----------------------------------------------------------------------------> +# <------ SOCKET VARIABLES ------> +# <----------------------------------------------------------------------------> + +# Variables that control the TCP server that outte starts in order to provide +# custom functionality to N++ players: +# +# 1) CUSE - Custom Userlevel Search Engine +# Generates custom userlevel searches that users with the corresponding tool +# can inject directly into their N++ game to expand the native searching +# functionalities by using outte's database of userlevels. +# 2) CLE - Custom Leaderboard Engine +# Provides 3rd party leaderboards hosted in outte's database, that people +# with the corresponding tool can connect to, so they can highscore custom +# mappacks. +# 3) API +# Provides certain outte functionality that can be called externally (e.g. +# from a browser) as opposed to from Discord or N++. + +SOCKETS = true # Allow open sockets +SOCKET_LOG = false # Log request and response details + +# CUSE-specific variables +QUERY_LIMIT_SOFT = 25 # Number of queried userlevels per page +QUERY_LIMIT_HARD = 500 # Maximum number of queried userlevels per page + +# CLE-specific variables +CLE_ON = true +CLE_PORT = 8126 +PWD = ENV['NPP_HASH'] +CLE_FORWARD = true # Forward unrelated requests to Metanet +INTEGRITY_CHECKS = true # Verity replay security hashes +WARN_INTEGRITY = true # Warn when players submit scores with invalid hashes +WARN_VERSION = false # Warning for score submissions with old map versions +LOCAL_LOGIN = true # Try to login user ourselves if Metanet fails +DEV_PLAYER_NAME = '-- DEV TIME --' # Player name to show dev scores +REPLAY_ID_BITS = 24 # See MappackHighscoreable.get_scores to understand this + +# API-specific variables +API_ON = true +API_PORT = 9000 +API_FAVICON = 'moleHack' +API_TEMPLATE = 'template.html' +API_STYLE = 'style.css' +API_SCRIPT = 'script.js' + +# <----------------------------------------------------------------------------> +# <------ GAME VARIABLES ------> +# <----------------------------------------------------------------------------> + +APP_ID = 230270 # N++'s Steam app ID +PACKAGE_ID = 94152 # N++'s Steam package ID +DEPOT_WIN_ID = 230271 +DEPOT_MACOS_ID = 230272 +DEPOT_LINUX_ID = 230273 +STEAM_BRANCH = 'public' +STEAM_COOLDOWN = 30 # Min seconds to wait between logins to Steamworks +BOTMASTER_NPP_ID = 54303 # Botmaster's N++ player ID +OUTTE_ID = 361131 # outte's N++ player ID +OUTTE2_ID = 409973 # outte's secondary N++ player +OUTTE_STEAM_ID = 76561199562076498 +OUTTE2_STEAM_ID = 76561199880867518 +DATA_STEAM_ID = OUTTE2_STEAM_ID # ID we use to perform the server queries +STEAM_TOKEN_GRACE = 7 # How many days before a refresh token expires to send out a warning to Discord +MIN_REPLAY_ID = 131072 # Minimum replay ID for the game to perform the HTTP request +MAGIC_EPISODE_VALUE = 0xffc0038e # First 4 bytes of a decompressed episode replay +MAGIC_STORY_VALUE = 0xff3800ce # First 4 bytes of a decompressed story replay + +# Mode stuff +MODE_SOLO = 0 +MODE_COOP = 1 +MODE_RACE = 2 +MODE_HC = 3 +MODES = { + -1 => "all", + MODE_SOLO => "solo", + MODE_COOP => "coop", + MODE_RACE => "race" +} + + +# Type stuff +TYPE_LEVEL = 0 +TYPE_EPISODE = 1 +TYPE_STORY = 2 + +# Properties of the different playing types +# id - Internal game index for the type +# name - Name of the type AND of the Rails model class +# slots - IDs reserved by N++ to this mode in the db +# min_scores - Max-min amount of scores to be taken into consideration for average rankings +# qt - Query type, index used by the game for server communications +# rt - Replay type, used for replay headers +# size - How many levels this type contains +TYPES = { + 'Level' => { + id: TYPE_LEVEL, + name: 'Level', + slots: 20000, + min_scores: 100, + qt: 0, + rt: 0, + size: 1 + }, + 'Episode' => { + id: TYPE_EPISODE, + name: 'Episode', + slots: 4000, + min_scores: 50, + qt: 1, + rt: 1, + size: 5 + }, + 'Story' => { + id: TYPE_STORY, + name: 'Story', + slots: 800, + min_scores: 10, + qt: 4, + rt: 0, + size: 25 + } +} + +# Tab stuff +TAB_INTRO = 0 +TAB_NPP = 1 +TAB_LEGACY = 2 +TAB_SECRET = 3 +TAB_ULTIMATE = 4 +TAB_SECRET_ULTIMATE = 5 +TAB_DLC = 6 + +# @par1: ID ranges for levels and episodes +# @par2: Score limits to filter new hacked scores +# @par3: Number of scores required to enter the average rank/point rankings of tab +TABS = { + "Episode" => { + :SI => [ ( 0.. 24).to_a, 400, 5], + :S => [ (120..239).to_a, 950, 25], + :SL => [ (240..359).to_a, 650, 25], + :SU => [ (480..599).to_a, 650, 25] + }, + "Level" => { + :SI => [ ( 0.. 124).to_a, 298, 25], + :S => [ ( 600..1199).to_a, 874, 50], + :SL => [ (1200..1799).to_a, 400, 50], + :SS => [ (1800..1919).to_a, 2462, 25], + :SU => [ (2400..2999).to_a, 530, 50], + :SS2 => [ (3000..3119).to_a, 322, 25], + :ST => [ (3600..3649).to_a, 400, 10] + }, + "Story" => { + :SI => [ ( 0.. 4).to_a, 1000, 1], + :S => [ (24.. 43).to_a, 2000, 5], + :SL => [ (48.. 67).to_a, 2000, 5], + :SU => [ (96..115).to_a, 1500, 5] + } +} + +# TODO: Add the stuff in TABS to TABS_NEW, then use TABS_NEW wherever TABS is +# is being used and also in parse_tabs, then delete TABS and rename TABS_NEW to TABS + +# All tab information. +TABS_NEW = { + SI: { + code: 'SI', + mode: MODE_SOLO, + tab: TAB_INTRO, + index: 0, + name: 'Intro', + start: 0, + size: 125, + files: { 'SI' => 125 }, + x: false, + secret: false, + challenges: false + }, + S: { + code: 'S', + mode: MODE_SOLO, + tab: TAB_NPP, + index: 1, + name: 'N++', + start: 600, + size: 600, + files: { 'S' => 600 }, + x: true, + secret: false, + challenges: true + }, + SL: { + code: 'SL', + mode: MODE_SOLO, + tab: TAB_LEGACY, + index: 3, + name: 'Legacy', + start: 1200, + size: 600, + files: { 'SL' => 600 }, + x: true, + secret: false, + challenges: false + }, + SS: { + code: '?', + mode: MODE_SOLO, + tab: TAB_SECRET, + index: 4, + name: 'Secret', + start: 1800, + size: 120, + files: { 'SS' => 120 }, + x: true, + secret: true, + challenges: true + }, + SU: { + code: 'SU', + mode: MODE_SOLO, + tab: TAB_ULTIMATE, + index: 2, + name: 'Ultimate', + start: 2400, + size: 600, + files: { 'S2' => 600 }, + x: true, + secret: false, + challenges: true + }, + SS2: { + code: '!', + mode: MODE_SOLO, + tab: TAB_SECRET_ULTIMATE, + index: 5, + name: 'Ultimate Secret', + start: 3000, + size: 120, + files: { 'SS2' => 120 }, + x: true, + secret: true, + challenges: true + }, + ST: { + code: '?!', + mode: MODE_SOLO, + tab: TAB_DLC, + index: 6, + name: 'TEN++', + start: 3600, + size: 50, + files: { 'SSS' => 25, 'SSS2' => 25 }, + x: false, + secret: true, + challenges: true + }, + CI: { + code: 'CI', + mode: MODE_COOP, + tab: TAB_INTRO, + index: 0, + name: 'Coop Intro', + start: 4200, + size: 50, + files: { 'CI' => 50 }, + x: false, + secret: false, + challenges: false + }, + C: { + code: 'C', + mode: MODE_COOP, + tab: TAB_NPP, + index: 1, + name: 'Coop', + start: 4800, + size: 600, + files: { 'C' => 300, 'C2' => 300 }, + x: true, + secret: false, + challenges: false + }, + CL: { + code: 'CL', + mode: MODE_COOP, + tab: TAB_LEGACY, + index: 2, + name: 'Coop Legacy', + start: 5400, + size: 330, + files: { 'CL' => 120, 'CL2' => 210 }, + x: true, + secret: false, + challenges: false + }, + CT: { + code: 'CT', + mode: MODE_COOP, + tab: TAB_DLC, + index: 3, + name: 'Coop TEN++', + start: 7800, + size: 150, + files: { 'CT' => 150 }, + x: true, + secret: false, + challenges: false + }, + RI: { + code: 'RI', + mode: MODE_RACE, + tab: TAB_INTRO, + index: 0, + name: 'Race Intro', + start: 8400, + size: 25, + files: { 'RI' => 25 }, + x: false, + secret: false, + challenges: false + }, + R: { + code: 'R', + mode: MODE_RACE, + tab: TAB_NPP, + index: 1, + name: 'Race', + start: 9000, + size: 600, + files: { 'R' => 300, 'R2' => 300 }, + x: true, + secret: false, + challenges: false + }, + RL: { + code: 'RL', + mode: MODE_RACE, + tab: TAB_LEGACY, + index: 2, + name: 'Race Legacy', + start: 9600, + size: 570, + files: { 'RL' => 120, 'RL2' => 450 }, + x: true, + secret: false, + challenges: false + }, + RT: { + code: 'RT', + mode: MODE_RACE, + tab: TAB_DLC, + index: 3, + name: 'Race TEN++', + start: 12000, + size: 150, + files: { 'RT' => 150 }, + x: true, + secret: false, + challenges: false + } +} + +TABS_SOLO = TABS_NEW.select{ |k, v| v[:mode] == MODE_SOLO } +TABS_COOP = TABS_NEW.select{ |k, v| v[:mode] == MODE_COOP } +TABS_RACE = TABS_NEW.select{ |k, v| v[:mode] == MODE_RACE } +TABS_SECRET = TABS_NEW.select{ |k, v| v[:secret] }.keys + +# <----------------------------------------------------------------------------> +# <------ HIGHSCORING VARIABLES ------> +# <----------------------------------------------------------------------------> + +DEFAULT_TYPES = ['Level', 'Episode'] # Default highscoreable types +MAX_TRACES = 4 # Maximum amount of simultaneous replays to trace + +MIN_TIES = 3 # Minimum number of ties for 0th to be considered maxable +MAX_SECS = 5 # Difference in seconds to consider two dates equal (for navigation) +MAXMIN_SCORES = 100 # max-min number of highscores to appear in average point rankings + +# Different ranking types +# * For parsing, 'top1' (i.e. 0th) will be removed (default) +# * For formatting, 'top1' will be changed to '0th' +RTYPES = [ + 'top1', + 'top5', + 'top10', + 'top20', + 'average_rank', + 'score', + 'cool', + 'star', + 'maxed', + 'maxable', + 'G++', + 'G--', + 'tied_top1', + 'singular_top1', + 'plural_top1', + 'average_top1_lead', + 'point', + 'average_point' +] + +# Players blacklisted from the leaderboards. +# Hackers: Fake scores. Rejected from database. +# Cheaters: Real but illegitimate scores. Archived in database, but hidden. +# Keys are the user IDs, values are their known usernames during their active period. +HACKERS = { + 42984 => ["Sirogabober"], + 63944 => ["Kronogenics"], + 72791 => ["Jett Altair"], + 75839 => ["vorcazm", "Treagus", "PROG2"], + 76223 => ["TylerDC"], + 107118 => ["Tabby_Cxt"], + 122681 => ["nietske"], + 128613 => ["cock unsucker"], + 135161 => ["Apjue"], + 146275 => ["Puce"], + 160134 => ["Mavlou"], + 163573 => ["gronk"], + 173617 => ["You have been banned."], + 201322 => ["dimitry008"], + 221472 => ["VexatiousCheff", "vex"], + 243184 => ["Player"], + 253072 => ["test8378"], + 253161 => ["Chara"], + 264603 => ["Croko"], + 276273 => ["DBYT3"], + 282291 => ["ill spiritual"], + 298531 => ["Boringfish"], + 307030 => ["The_Mega_Force"], + 325245 => ["Staticwork"], + 326339 => ["Psina"], + 332348 => ["DE321F-0"], + 333510 => ["dominater01"], + 336069 => ["Progressively idle"], + 353512 => ["Dragon_V.V"], + 357282 => ["MX_Er3P"], + 379163 => ["ジャイロ"], + 401089 => ["Haidi"], + 409973 => ["outte++2"], + 411213 => ["Nacnud54"], + 414614 => ["Λ"], + 424914 => [">:("], + 428175 => ["Jake_"], + 433645 => ["Gan"], + 434323 => ["ilvory"] +} + +CHEATERS = { + 115572 => ["Mishu"], + 317061 => ["jake"] +} + +# Additional blacklisted names whose ID we don't know, since their scores +# were cleaned long ago (we still hold a grudge tho) +BLACKLIST_NAMES = [ + "BlueIsTrue", + "fiordhraoi", + "cheeseburgur101", + "Jey", + "jungletek", + "Hedgy", + "ᕈᘎᑕᒎᗩn ᙡiᗴᒪḰi", + "Venom", + "EpicGamer10075", + "Altii", + "Floof The Goof", + "Prismo" +] + +# Patched runs from legitimate players because they were done +# with older versions of levels and the scores are now incorrect. +# @params: maximum replay id of incorrect scores, score adjustment required +PATCH_RUNS = { + :episode => { + 182 => [695142, -42], # S-C-12 + 217 => [1165074, -8], # S-C-19 + 509 => [2010381, -6] # SU-E-05 + }, + :level => { + 910 => [286360, -42], # S-C-12-00 + 1089 => [225710, -8], # S-C-19-04 + 2549 => [2000000, -6] # SU-E-05-04 + }, + :story => { + }, + :userlevel => { + } +} + +# Delete individual runs +PATCH_IND_DEL = { + :episode => [ + 5035576, # proxy17585's SI-C-00 + 5073211 # HamSandwich's SI-D-00 + ], + :level => [ + 3572785, # SuperVolcano's S-B-00-01 + 3622469, # HamSandwich's S-B-00-02 + 3845087, # crit's SI-A-00-00 + 3845089, # crit's SI-A-00-03 + 3845119, # crit's SI-C-00-01 + 3845121, # crit's SI-C-00-03 + 3845158, # crit's SI-B-01-02 + 3845166, # crit's SI-C-01-01 + 3845167, # crit's SI-C-01-02 + 3846047 # crit's S-A-00-01 + ], + :story => [], + :userlevel => [ + 2649242 # ekisacik's run in 68214 + ] +} + +# Patch individual runs (by changing score) +PATCH_IND_CHG = { + :episode => { + 5067031 => -6 # trance's SU-E-05 + }, + :level => { + 3758900 => -6 # trance's SU-E-05-04 + }, + :story => {}, + :userlevel => {} +} + +# For these boards, replays won't be visible +PROTECTED_BOARDS = { + 'ape' => ['S-A-00', 'S-A-00-00'], + 'eon' => ['S-A-01', 'S-A-01-00'] +} + +# For these boards, scores (and replays) won't be visible +PRIVATE_BOARDS = { +} diff --git a/src/inne++.rb b/src/inne++.rb new file mode 100644 index 00000000..5b00b752 --- /dev/null +++ b/src/inne++.rb @@ -0,0 +1,523 @@ +################################################################################ +# # +# READ THE FOLLOWING INSTRUCTIONS # +# TO SET UP THE BOT FOR THE FIRST TIME # +# # +# ////// Dependencies and pre-requirements: # +# # +# - MySQL 5.7 or higher. Use utf8mb4 for both encoding and collation, either # +# server-wide or at least for outte's database. I recommend to use # +# the my.cnf configuration file provided in ./util/my.cnf # +# # +# - Ruby 2.7 or higher. If you have other versions you want to maintain, # +# I recommend to install rbenv and use it to manage multiple simultaneous # +# Ruby installations in your environment. # +# # +# To ensure you have the correct version of all the gems (libraries) # +# required by outte, install the bundler gem and then run "bundle install" # +# # +# ////// Optional dependencies: # +# # +# - Python 3 for the replay tracing capabilities. To disable it, toggle the # +# FEATURE_NTRACE variable off in src/constants.rb # +# # +# ////// Setting up the bot: # +# # +# 0) Create a Discord bot associated to your account, you may follow any # +# standard guide for this. Typically, a moderator will have to authorize # +# your bot into the server. # +# # +# Optionally, but recommended, create another bot for development. You can # +# use this one in a custom server or via DMs, without interferring with # +# the production bot. # +# # +# 1) Set up the following environment variables: # +# * DISCORD_CLIENT : Discord's application client ID for your bot. # +# * DISCORD_TOKEN : Discord's application secret for your bot. # +# # +# Optionally, set up the following environment variables: # +# # +# If you want Twitch integration, to fetch new N++-related streams: # +# * TWITCH_CLIENT : Client ID for your Twitch app. # +# * TWITCH_SECRET : Secret for your Twitch app. # +# Alternatively, toggle the UPDATE_TWITCH constant off. # +# # +# If you have a secondary bot for development: # +# * DISCORD_CLIENT_TEST : Same as DISCORD_CLIENT for your test bot. # +# * DISCORD_TOKEN_TEST : Same as DISCORD_TOKEN for your test bot. # +# Alternatively, never toggle the TEST constant on. # +# # +# If you want the security hash integrity checks for score submission: # +# * NPP_HASH : Secret password used by N++, contact Eddy for it. # +# Alternatively, toggle INTEGRITY_CHECKS off. # +# # +# 2) Configure the database environment in ./db/config.yml, by either: # +# - Setting up the "outte" environment, or # +# - Creating a new one, and renaming the DATABASE constant. # +# If you don't need to change anything, the default outte environment # +# provided in that file should work well. # +# # +# 3) Set up the database, by either: # +# - Creating a database named "inne", running all the migrations, and # +# seeding the data. Then, the first time the bot is run, userlevels # +# and scores will be downloaded. # +# - Asking for a current copy of the database. This is simpler, as it # +# skips many delicate aspects about setting and configuring the # +# database properly (see Contact). # +# # +# 4) Configure additional settings in src/constants.rb. For example: # +# BOTMASTER_ID - Your Discord user ID. # +# SERVER_ID - N++'s Discord server ID. # +# CHANNEL_HIGHSCORES - #highscores channel ID in N++'s server. # +# TEST - Toggles between production and development bot. # +# DO_NOTHING - Don't execute any threads (e.g. score update). # +# DO_EVERYTHING - Execute all threads. # +# # +# 5) Optionally, build the native C extension by running the build.sh script # +# stored in the /ext folder. See the README for more instructions. # +# # +# ////// Other notes: # +# # +# - Edit and save the source files in UTF8. # +# - Always run outte from the root directory, NOT from the src directory or # +# any other one. # +# - You can find more detailed information in the tool's repo (see below). # +# # +# ////// Contact & Source: # +# # +# Eddy @ https://discord.gg/nplusplus # +# https://github.com/edelkas/inne # +# # +################################################################################ + +$boot_time = Time.now +$load_time = Time.now # Reset on each file load + +# We use some gems directly from Github repositories. This is supported by +# Bundler but not by RubyGems directly. The next two lines makes these gems +# available / visible. +require 'rubygems' +require 'bundler/setup' + +# Gems useful throughout the entire program +# (each source file might contain further specific gems) +require 'discordrb' +require 'fileutils' +require 'json' +require 'memory_profiler' +require 'net/http' +require 'open3' +require 'pry-byebug' +require 'rbconfig' +require 'stringio' +require 'time' +require 'yaml' +require 'zlib' +now = Time.now +puts("[%s] i Loaded libraries (%.2fs)" % [now.strftime("%Y/%m/%d %H:%M:%S.%L"), now - $boot_time]) + +# Import all other source files (the order matters!) +t = Time.now +require_relative 'constants.rb' +require_relative 'utils.rb' +require_relative 'io.rb' +require_relative 'interactions.rb' +require_relative 'maps.rb' +require_relative 'models.rb' +require_relative 'api.rb' +require_relative 'userlevels.rb' +require_relative 'mappacks.rb' +require_relative 'messages.rb' +require_relative 'admin.rb' +require_relative 'threads.rb' + +# Import our own native C extension +begin + require_relative File.join('..', DIR_LIB, 'cinne') +rescue LoadError + err("Failed to load C-inne, is it built?") +end +log("Loaded source files (%.2fs)" % [Time.now - t]) +$load_time = Time.now + +# We monkey patch a few core classes (Enumerable, Array, boolean classes...) +# and several of the gems (ActiveRecord, Discordrb...) +# See the MonkeyPatch module in models.rb for the details +def monkey_patch + MonkeyPatches.apply + log("Applied monkey patches") +rescue => e + fatal("Failed to apply monkey patches: #{e}") +end + +# Initialize the global variables used by the bot +# Also set some environment variables and ensure some folders are created +def initialize_vars + t = Time.now + + # Initialize global variables + $config = nil + $channel = nil + $components = nil + $mapping_channel = nil + $nv2_channel = nil + $content_channel = nil + $last_potato = Time.now.to_i + $potato = 0 + $last_mishu = nil + $status_update = Time.now.to_i + $twitch_token = nil + $twitch_streams = {} + $active_tasks = {} + $last_steamworks = nil + $memory_warned = false + $memory_warned_c = false + $linux = RbConfig::CONFIG['host_os'].match?(/linux/i) + $c_inne = !!defined?(C_INNE) + $mutex = { trace: Mutex.new, nsim: Mutex.new, tmp_msg: Mutex.new } + $log = { socket: SOCKET_LOG } + $tools = { python: nil } + $threads = {} + $servers = {} + $main_queue = Queue.new + $sql_vars = {} + $sql_status = {} + $sql_conns = [] + $emojis = {} + $trace_context = { + h: nil, + theme: "", + bg: nil, + nsim: [], + markers: [], + texts: [] + } + + # Find Python interpreter + $tools[:python] = ['python', 'python3'].find{ |s| system("#{s} --version > /dev/null 2>&1") } + alert("No Python interpreter found, some features will not work.") if !$tools[:python] + $tools[:python_fast] = ['pypy', 'pypy3'].find{ |s| system("#{s} --version > /dev/null 2>&1") } + + # Parse available Steam refresh tokens + $steam_tokens = ENV.select{ |k, v| k =~ /^STEAM_TOKEN_\d+$/i }.map{ |k, v| + steam_id = k[/\d+/].to_i + res = parse_steam_jwt(v, steam_id, silent: false) + [steam_id, { id: steam_id, token: v, warned: false }.merge(res)] + }.to_h + + # Parse available API keys + $api_keys = ENV.map{ |k, v| + [$1.downcase, $2.downcase, v] if k =~ /^KEY_([A-Z0-9]+)_([A-Z0-9]+)$/i + }.compact.group_by{ |platform, api, key| platform }.map{ |platform, list| + [ + platform.to_sym, + list.map{ |p, a, k| + dbg("Found key for #{p} #{a} API") + [a.to_sym, k] + }.to_h + ] + }.to_h + + # Set environment variables + ENV['DISCORDRB_NONACL'] = '1' # Prevent libsodium warning message + + # Create additional needed folders + [DIR_LOGS].each{ |d| Dir.mkdir(d) unless Dir.exist?(d) } + + log("Initialized global variables (%.2fs)" % [Time.now - t]) +rescue => e + fatal("Failed to initialize global variables: #{e}") +end + +# Parse the database configuration file, as well as some environment variables +def load_config + $config = YAML.load_file(CONFIG)[DATABASE] + $config['discord_client'] = (TEST ? ENV['DISCORD_CLIENT_TEST'] : ENV['DISCORD_CLIENT']).to_i + $config['discord_secret'] = TEST ? ENV['DISCORD_TOKEN_TEST'] : ENV['DISCORD_TOKEN'] + $config['twitch_client'] = ENV['TWITCH_CLIENT'] + $config['twitch_secret'] = ENV['TWITCH_SECRET'] + log("Loaded config") +rescue => e + fatal("Failed to load config: #{e}") +end + +# Connect to the database +def connect_db + t = Time.now + Time.zone = 'UTC' + ActiveRecord::Base.establish_connection($config) + GlobalProperty.status_init + log("Connected to database (%.2fs)" % [Time.now - t]) +rescue => e + fatal("Failed to connect to the database: #{e}") +end + +# Disconnect from the database +def disconnect_db + ActiveRecord::Base.connection_handler.clear_active_connections! + ActiveRecord::Base.connection.disconnect! + ActiveRecord::Base.connection.close + log("Disconnected from database") +rescue => e + fatal("Failed to disconnect from the database: #{e}") +end + +# Create and configure the bot +def create_bot + $bot = Discordrb::Bot.new( + token: $config['discord_secret'], + client_id: $config['discord_client'], + log_mode: :quiet, + intents: [ + :servers, + :server_members, + :server_bans, + :server_emojis, + :server_integrations, + :server_webhooks, + :server_invites, + :server_voice_states, + #:server_presences, + :server_messages, + :server_message_reactions, + :server_message_typing, + :direct_messages, + :direct_message_reactions, + :direct_message_typing + ] + ) + log("Created bot") +rescue => e + fatal("Failed to create bot: #{e}") +end + +# Prepare response to a command (new message / edit message) +def craft_response(event, func) + func.call(event) +rescue OutteError => e + # These exceptions are manually triggered errors, usually user errors that + # we may want to log back to Discord + + msg = e.message.strip + return if msg.empty? + err(msg) if e.log + return if !e.discord + is_auto = event.is_a?(Discordrb::Events::Respondable) + if tmp_msg = TmpMsg.fetch(event) + tmp_msg.edit(msg, temp: false) + event.drain if is_auto + elsif is_auto + event << msg + else + send_message(event, content: msg, log: false, edit: false, ephemeral: e.ephemeral) + end +rescue => e + # These exceptions are internal errors, so send warning to the channel and + # log full trace to the terminal/log file + + lex(e, "Error parsing message.", event: event) +ensure + TmpMsg.delete(event) +end + +# Handle a new command, by crafting a response and sending it appropriately +def handle_command(event, &func) + # Return if responding is disabled, unless we're the botmaster + return if !RESPOND && !is_botmaster?(event) + special = false + + # Parse the command and log it + case event + when Discordrb::Events::MessageEvent + msg = parse_message(event) + remove_mentions!(msg) + special = msg[0] == '!' && is_botmaster?(event) + if special + log_msg = "Special command: #{msg}" + elsif event.channel.type == 1 + log_msg = "DM by [#{event.user.name}]: #{msg}" + else + log_msg = "Mention by [#{event.user.name}] in [#{event.channel.name}]: #{msg}" + end + special ? succ(log_msg) : msg(log_msg) + when Discordrb::Events::ApplicationCommandEvent + lin("Application command [#{event.command_name}] used by [#{event.user.name}] in [#{event.channel.name}]") + when Discordrb::Events::ButtonEvent + lin("Button [#{event.custom_id}] pressed by [#{event.user.name}] in [#{event.channel.name}]") + when Discordrb::Events::StringSelectEvent + lin("Select menu [#{event.custom_id}] used by [#{event.user.name}] in [#{event.channel.name}]: [#{event.values.join(', ')}]") + end + + # Write up response and send it + acquire_connection + initialize_components + func = special ? -> (e) { respond_special(e) } : -> (e) { respond(e) } if !func + craft_response(event, func) + send_message(event) + initialize_components +ensure + # Ensure to disconnect, otherwise connections leak and the pool fills + release_connection +end + +# Setup triggers for DMs, mentions, messages and interactions. +# Discordrb creates a new thread for each of these, so we must either take +# a db connection from the pool or remember to disconnect at the end to prevent +# zombie connections. +def setup_bot + # Respond to DMs + $bot.private_message do |event| + action_inc('dms') + handle_command(event) + rescue => e + lex(e, 'Failed to handle Discord DM') + end + + # Respond to pings + $bot.mention do |event| + action_inc('pings') + handle_command(event) unless event.channel.type == 1 + rescue => e + lex(e, 'Failed to handle Discord ping') + end + + # Parse all messages, and optionally respond + $bot.message do |event| + next if !RESPOND && !is_botmaster?(event) + msg = parse_message(event) + remove_mentions!(msg) + + if event.channel == $nv2_channel + $last_potato = Time.now.to_i + $potato = 0 + end + mishnub(event) if MISHU && msg.downcase.include?("mishu") + robot(event) if !!msg[/eddy\s*is\s*a\s*robot/i] + rescue => e + lex(e, 'Failed to handle Discord message') + end + + # Respond to button interactions + $bot.button do |event| + action_inc('interactions') + handle_command(event) { |e| respond_interaction_button(e) } + rescue => e + lex(e, 'Failed to handle Discord button interaction') + end + + # Respond to select menu interactions + $bot.select_menu do |event| + action_inc('interactions') + handle_command(event) { |e| respond_interaction_menu(e) } + rescue => e + lex(e, 'Failed to handle Discord select menu interaction') + end + + # Respond to text input interactions + $bot.modal_submit do |event| + action_inc('interactions') + handle_command(event) { |e| respond_interaction_modal(e) } + rescue => e + lex(e, 'Failed to handle Discord text input interaction') + end + + # Parse new reactions + $bot.reaction_add do |event| + handle_command(event) { |e| respond_reaction(e) } + rescue => e + lex(e, 'Failed to handle Discord reaction') + end + + # Parse application commands + handler = Proc.new do |event| + handle_command(event) { |e| respond_application_command(e) } + rescue => e + lex(e, 'Failed to handle Discord application command') + end + register_command_handlers(&handler) + + log("Configured bot") +rescue => e + fatal("Failed to configure bot: #{e}") +end + +# Start running the bot, and set up an interrupt trigger to shut it down +def run_bot + t = Time.now + $bot.run(true) + ['INT', 'TERM'].each { |signal| + trap(signal) { + alert("SIG#{signal} received, shutting down...") + shutdown(trap: true, force: true) + exit + } + } + leave_unknown_servers + register_commands + fetch_app_emojis + succ("Bot connected to servers: #{$bot.servers.map{ |id, s| s.name }.join(', ')} (%.2fs)." % [Time.now - t]) +rescue => e + fatal("Failed to execute bot: #{e}") +end + +# Stop running the bot +def stop_bot + $bot.stop + log("Stopped bot") +rescue => e + fatal("Failed to stop the bot: #{e}") +end + +# Routine to shutdown the program (exit should be called afterwards) +def shutdown(trap: false, force: false) + # Stop all background tasks gracefully, unless forcefully killing outte + if !force && !Scheduler.free? + names = Scheduler.list_blocking.map{ |job| job.task.name }.join(", ") + names = 'blocking threads' if names.empty? + alert("Waiting for background tasks to finish (#{names})") + Scheduler.free + sleep(0.1) while !Scheduler.free? + end + + # Stop bot, shutdown servers, disconnect from DB + stop_bot + $servers.each{ |name, svr| svr.stop(!force) } + #disconnect_db unless trap + err("Shut down outte") +rescue => e + fatal("Failed to shut down bot: #{e}") +end + +# Bot initialization sequence +initialize_vars +monkey_patch +load_config +connect_db +create_bot +setup_bot +start_general_tasks +start_metanet_tasks +_thread do + run_bot + set_channels + start_discord_tasks +end unless DAEMON + +# Done loading +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) +succ("Loaded outte (%.2fs)" % [Time.now - $boot_time]) +binding.pry if DEBUG + +# Idle until we need to execute commands on the main thread issued from +# different threads +while cmd = $main_queue.pop + action_inc('main_commands') + case cmd.proc + # Matplotlib depends on PyCall, which is not thread safe + when :trace + cmd.result = Map::mpl_trace(**$trace_context) + else + action_dec('main_commands') + end + cmd.thread.run +end diff --git a/src/interactions.rb b/src/interactions.rb new file mode 100644 index 00000000..2bac9b57 --- /dev/null +++ b/src/interactions.rb @@ -0,0 +1,767 @@ +# This file handles outte's usage of Discord's interactions. +# These can either be: +# - Application commands +# - Modal windows +# - Message components: Buttons, select menus and text inputs. +# - New (V2) components: Sections, containers, media galleries, etc. + +$load_time = Time.now + +def create_components + Discordrb::Webhooks::View.new +end + +def initialize_components + $components = create_components +end + +# Add a section to a view. The thumbnail and button accesories are mutually exclusive. +def view_add_section( + texts: [], # Text elements (1-3) to show inside the section + thumbnail: nil, # Thumbnail File object + description: nil, # Thumbnail description + button: nil, # Button label + custom_id: nil, # Button custom ID + style: :primary, # Button style (:primary, :secondary, :success, :danger, :link) + emoji: nil, # Button emoji + disabled: false, # Button enabled or disabled + url: nil, # Button URL for :link type ones + view: create_components() + ) + view.section do |section| + texts.each{ |text| section.text_display(content: text) unless text.strip.empty? } + section.thumbnail(url: file_url(thumbnail), description: description) if thumbnail + section.button(style: style, label: button, emoji: emoji, custom_id: custom_id, disabled: disabled, url: url) if button + end + view +end + +def view_add_text(content: '', id: nil, view: create_components()) + view.text_display(content: content, id: id) + view +end + +def view_add_file(file: nil, id: nil, spoiler: false, view: create_components()) + return view if !file + view.file(url: file_url(file), id: id, spoiler: spoiler) + view +end + +def view_add_gallery(items: [], spoiler: false, id: nil, view: create_components()) + return view if items.empty? + view.media_gallery(id: id) do |gallery| + items.each{ |item| + url = item.is_a?(File) ? file_url(item) : item + gallery.item(url: url, spoiler: spoiler) + } + end + view +end + +def view_nav(event, page, count, view: nil, func: nil, size: 1) + pag = get_paging(event, page, count, size) + interaction_add_button_navigation(view, pag[:page], pag[:pages], func: func) +end + +# ActionRow builder for a generic Select Menu +def interaction_add_select_menu(view = nil, id = 'menu', names = [], default = '', placeholder = 'Section') + view = Discordrb::Webhooks::View.new if !view + view.row do |row| + row.select_menu(custom_id: id, placeholder: placeholder, max_values: 1) do |menu| + names.each.with_index do |name, i| + menu.option(label: name, value: i.to_s, default: name == default) + end + end + end +ensure + return view +end + +# ActionRow builder with a Select Menu for the mode +# mode: Name of mode that is currently selected +# all: Whether to allow an "All" option +def interaction_add_select_menu_mode(view = nil, id = '', mode = nil, all = true) + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + r.select_menu(custom_id: "#{id}:mode", placeholder: 'Mode', max_values: 1){ |m| + MODES.reject{ |k, v| all ? false : v == 'all' }.each{ |k, v| + m.option(label: "Mode: #{v.capitalize}", value: v, default: v == mode) + } + } + } +ensure + return view +end + +# ActionRow builder with a Select Menu for the tab +def interaction_add_select_menu_tab(view = nil, id = '', tab = nil) + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + r.select_menu(custom_id: "#{id}:tab", placeholder: 'Tab', max_values: 1){ |m| + USERLEVEL_TABS.each{ |t, v| + m.option(label: "Tab: #{v[:fullname]}", value: v[:name], default: v[:name] == tab) + } + } + } +ensure + return view +end + +# ActionRow builder with a Select Menu for the order +# order: The name of the current ordering +# default: Whether to plug "Default" option at the top +def interaction_add_select_menu_order(view = nil, id = '', order = nil, default = true) + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + r.select_menu(custom_id: "#{id}:order", placeholder: 'Order', max_values: 1){ |m| + ["default", "title", "date", "favs"][(default ? 0 : 1) .. -1].each{ |b| + m.option(label: "Sort by: #{b.capitalize}", value: b, default: b == order) + } + } + } +ensure + return view +end + +# ActionRow builder with a Select Menu for the highscoreable type +# (All, Level, Episode, Story) +def interaction_add_select_menu_type(view = nil, type = nil) + type = 'overall' if type.nil? || type.empty? + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + r.select_menu(custom_id: 'menu:type', placeholder: 'Type', max_values: 1){ |m| + ['overall', 'level', 'episode', 'story'].each{ |b| + label = b == 'overall' ? 'Levels + Episodes' : b.capitalize.pluralize + m.option(label: "Type: #{label}", value: b, default: b == type) + } + } + } +ensure + return view +end + +# ActionRow builder with a Select menu for the highscorable tabs +# (All, SI, S, SU, SL, SS, SS2, ST) +def interaction_add_select_menu_metanet_tab(view = nil, id = '', tab = nil) + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + r.select_menu(custom_id: "#{id}:tab", placeholder: 'Tab', max_values: 1){ |m| + ['all', 'si', 's', 'su', 'sl', 'ss', 'ss2', 'st'].each{ |t| + m.option( + label: t == 'all' ? 'All tabs' : format_tab(t.upcase.to_sym) + ' tab', + value: t, + default: t == tab + ) + } + } + } +ensure + return view +end + +# ActionRow builder with a Select menu for the ranking types +# (0th, Top5, Top10, Top20, Average rank, +# 0th (w/ ties), Tied 0ths, Singular 0ths, Plural 0ths, Average 0th lead +# Maxed, Maxable, Score, Points, Average points) +def interaction_add_select_menu_rtype(view = nil, id = '', rtype = nil) + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + r.select_menu(custom_id: "#{id}:rtype", placeholder: 'Ranking type', max_values: 1){ |m| + RTYPES.each{ |t| + m.option( + label: "#{format_rtype(t).gsub(/\b(\w)/){ $1.upcase }}", + value: t, + default: t == rtype + ) + } + } + } +ensure + return view +end + +# ActionRow builder with a Select Menu for the alias type +def interaction_add_select_menu_alias_type(view = nil, type = nil) + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + r.select_menu(custom_id: 'alias:type', placeholder: 'Alias type', max_values: 1){ |m| + ['level', 'player'].each{ |b| + m.option(label: "#{b.capitalize} aliases", value: b, default: b == type) + } + } + } +ensure + return view +end + +# Template ActionRow builder with Buttons for navigation +def interaction_add_navigation( + view = nil, + labels: ['First', 'Previous', 'Current', 'Next', 'Last'], + disabled: [false, false, true, false, false], + ids: ['button:nav:first', 'button:nav:prev', 'button:nav:cur', 'button:nav:next', 'button:nav:last'], + styles: [:primary, :primary, :secondary, :primary, :primary], + emojis: [nil, nil, nil, nil, nil] + ) + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + r.button(label: labels[0], style: styles[0], disabled: disabled[0], custom_id: ids[0], emoji: emojis[0]) + r.button(label: labels[1], style: styles[1], disabled: disabled[1], custom_id: ids[1], emoji: emojis[1]) + r.button(label: labels[2], style: styles[2], disabled: disabled[2], custom_id: ids[2], emoji: emojis[2]) + r.button(label: labels[3], style: styles[3], disabled: disabled[3], custom_id: ids[3], emoji: emojis[3]) + r.button(label: labels[4], style: styles[4], disabled: disabled[4], custom_id: ids[4], emoji: emojis[4]) + } +ensure + return view +end + +# Template ActionRow builder with Buttons for navigation (short version) +def interaction_add_navigation_short( + view = nil, + labels: ['Previous', 'Current', 'Next'], + disabled: [false, true, false], + ids: ['button:nav:prev', 'button:nav:cur', 'button:nav:next'], + styles: [:primary, :secondary, :primary], + emojis: [nil, nil, nil] + ) + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + r.button(label: labels[0], style: styles[0], disabled: disabled[0], custom_id: ids[0], emoji: emojis[0]) + r.button(label: labels[1], style: styles[1], disabled: disabled[1], custom_id: ids[1], emoji: emojis[1]) + r.button(label: labels[2], style: styles[2], disabled: disabled[2], custom_id: ids[2], emoji: emojis[2]) + } +ensure + return view +end + + +# ActionRow builder with Buttons for standard page navigation +def interaction_add_button_navigation(view, page = 1, pages = 1, offset = 1000000000, func: nil, force: false, source: 'button') + view = Discordrb::Webhooks::View.new if !view + return view if pages == 1 && !force + interaction_add_navigation( + view, + labels: ["❙❮", "❮", "#{page} / #{pages}", "❯", "❯❙"], + disabled: [page == 1, page == 1, true, page == pages, page == pages], + ids: [ + "#{source}:nav:#{-offset}#{func ? ':' + func : ''}", + "#{source}:nav:-1#{func ? ':' + func : ''}", + "#{source}:nav:page", + "#{source}:nav:1#{func ? ':' + func : ''}", + "#{source}:nav:#{offset}#{func ? ':' + func : ''}" + ] + ) +end + +# ActionRow builder with Buttons for standard page navigation +# - func: The name of the function this button will call +# - wrap: Whether buttons will wrap once you get to the end of the list, or be disabled instead +# - force: Force buttons to show even if there's a single page +# - total: Show the total amount of pages, useful for when it's not known +def interaction_add_button_navigation_short(view, page = 1, pages = 1, func = nil, wrap: false, force: false, total: true, source: 'button') + view = Discordrb::Webhooks::View.new if !view + return view if pages == 1 && !force + offset_left = wrap && page == 1 ? 1000000000 : -1 + offset_right = wrap && page == pages ? -1000000000 : 1 + suf = func ? ':' + func : '' + interaction_add_navigation_short( + view, + labels: ["❮", page.to_s + (total ? " / #{pages}" : ''), "❯"], + disabled: [wrap ? false : page == 1, true, wrap ? false : page == pages], + ids: [ + "#{source}:nav:#{offset_left}#{suf}", + "#{source}:nav:page", + "#{source}:nav:#{offset_right}#{suf}" + ] + ) +end + +# ActionRow builder with Buttons for page navigation, together with center action button +def interaction_add_action_navigation(view, page = 1, pages = 1, action = '', text = '', emoji = nil, source: 'button') + emoji = find_emoji(emoji).id rescue nil if emoji && emoji.ascii_only? + text = "#{page} / #{pages}" if text.empty? && emoji.nil? + interaction_add_navigation( + view, + labels: ["❙❮", "❮", text, "❯", "❯❙"], + disabled: [page == 1, page == 1, false, page == pages, page == pages], + styles: [:primary, :primary, :success, :primary, :primary], + emojis: [nil, nil, emoji, nil, nil], + ids: [ + "#{source}:nav:-1000000", + "#{source}:nav:-1", + "#{source}:#{action}:", + "#{source}:nav:1", + "#{source}:nav:1000000" + ] + ) +end + +# ActionRow builder with Buttons for level/episode/story navigation +def interaction_add_level_navigation(view, name) + interaction_add_navigation( + view, + labels: ["❮❮", "❮", name, "❯", "❯❯"], + disabled: [false, false, true, false, false], + ids: [ + "nav_scores:h:-2", + "nav_scores:h:-1", + "nav_scores:h:page", + "nav_scores:h:1", + "nav_scores:h:2" + ] + ) +end + +# ActionRow builder with Buttons for date navigation +def interaction_add_date_navigation(view, page = 1, pages = 1, date = 0, label = "") + interaction_add_navigation( + view, + labels: ["❙❮", "❮", label, "❯", "❯❙"], + disabled: [page == 1, page == 1, true, page == pages, page == pages], + ids: [ + "nav_scores:date:-1000000000", + "nav_scores:date:-1", + "nav_scores:date:#{date}", + "nav_scores:date:1", + "nav_scores:date:1000000000" + ] + ) +end + +# ActionRow builder with Buttons to specify type (Level, Episode, Story) +# in Rankings, also button to include ties. +def interaction_add_type_buttons(view = nil, types = [], ties = nil) + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + TYPES.each{ |t, h| + r.button( + label: h[:name].capitalize.pluralize, + style: types.include?(h[:name].capitalize) ? :success : :danger, + custom_id: "button:type:#{h[:name].downcase}" + ) + } + r.button(label: 'Ties', style: ties ? :success : :danger, custom_id: "button:ties:#{!ties}") + } +ensure + return view +end + +# ActionRow builder with Yes/No buttons to confirm an action +def interaction_add_confirmation_buttons(view = nil) + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + r.button(label: 'Yes', style: :success, custom_id: 'button:confirm:yes') + r.button(label: 'No', style: :danger, custom_id: 'button:confirm:no') + } +ensure + return view +end + +def interaction_button(text = "Text", id = 'test', view = nil, style: :primary) + view = Discordrb::Webhooks::View.new if !view + view.row{ |r| + r.button(label: text, style: style, custom_id: "button:#{id}") + } +ensure + return view +end + +def refresh_button(id = 'test') + interaction_button("⟳", "refresh:#{id}") +end + +def add_refresh_button(id = 'test') + $components.row{ |r| + r.button(label: "⟳", style: :primary, custom_id: "refresh:#{id}") + } +end + +def modal( + event, + title: 'Modal', + custom_id: 'modal:test', + style: :short, + label: 'Enter text:', + min_length: 0, + max_length: 64, + required: false, + value: nil, + placeholder: 'Placeholder' + ) + event.show_modal(title: title, custom_id: custom_id) do |modal| + modal.label do |label| + label.text_input( + style: style, + custom_id: 'name', + label: label, + min_length: min_length, + max_length: max_length, + required: required, + value: value, + placeholder: placeholder + ) + end + end +end + +def parse_modal_values(event) + event.components.map{ |component| + next unless component.is_a?(Discordrb::Components::Label) + c = component.component + multiple = [ + Discordrb::Components::CheckboxGroup, + Discordrb::Components::FileUpload, + Discordrb::Components::SelectMenu + ] + is_list = multiple.include?(c.class) + [c.custom_id, is_list ? c.values : c.value] + }.compact.to_h +end + +# Get a new builder based on a pre-existing component collection (i.e., for +# messages that have already been sent, so that we can send the same components +# back automatically). +def to_builder(components) + view = Discordrb::Webhooks::View.new + components.each{ |row| + view.row{ |r| + row.components.each{ |c| + case c + when Discordrb::Components::Button + r.button( + label: c.label, + style: c.style, + emoji: c.emoji, + custom_id: c.custom_id, + disabled: c.disabled, + url: c.url + ) + when Discordrb::Components::SelectMenu + r.select_menu( + custom_id: c.custom_id, + min_values: c.min_values, + max_values: c.max_values, + placeholder: c.placeholder + ) { |m| + c.options.each{ |o| + m.option( + value: o.value, + label: o.label, + emoji: o.emoji, + description: o.description + ) + } + } + end + } + } + } + view +rescue + Discordrb::Webhooks::View.new +end + +# Given a message event, presumably with select menus, this will fetch the +# current value of a menu given the id. +# It assumes the last part of the custom ID of the select menu is the current value +def get_menu_value(event, id) + event.message.components.each{ |row| + row.components.each{ |c| + return $2 if c.custom_id =~ /^(.+):(.+)$/ && $1 == id + } + } + nil +end + +# Modal shown to identify a player (i.e., assign a player to a Discord user), +# if the player is found, otherwise respond with an error +def modal_identify(event, name: '') + name.strip! + user = parse_user(event.user) + player = Player.find_by(name: name) + + if !player + user.player = nil + event.respond( + content: "No player found by the name #{verbatim(name)}, did you write it correctly?", + ephemeral: true + ) + return false + end + + user.player = player + event.respond(content: "Identified correctly, you are #{verbatim(name)}.", ephemeral: true) +end + +# Called after clicking button to confirm the deletion of a mappack score +def delete_score(event, yes: false) + expired = Time.now - event.message.timestamp > CONFIRM_TIMELIMIT + return send_message(event, content: 'Dismissed.', append: true) if !yes + return send_message(event, content: 'Expired.', append: true) if expired + + id = event.message.content[/\(ID\s+(\d+)\)/i, 1] + perror("Score ID not found in source message.") if !id + score = MappackScore.find_by(id: id.to_i) + perror("Mappack score with ID #{id} not found.") if !score + score.destroy + + msg = "#{score.player.name}'s score (ID #{id}) in #{score.highscoreable.name}" + msg = score.destroyed? ? "Deleted #{msg}." : "Failed to delete #{msg}." + send_message(event, content: msg, append: true) +end + +# Register a global command. Will update (overwrite) if it already exists. +def register_command(cmd, update = false, server_id: nil) + case cmd + when :browse + $bot.register_application_command(:browse, 'Browse or search userlevels', server_id: server_id) do |cmd| + cmd.string(:name, 'Search by title') + cmd.string(:author, 'Search by author name') + cmd.string(:mode, 'Gameplay mode', choices: { 'Solo' => 'solo', 'Coop' => 'coop', 'Race' => 'race' }) + cmd.string(:tab, 'Search in a specific tab', choices: { 'Featured' => 'featured', 'Hardest' => 'hardest', 'Best' => 'best', 'Top Weekly' => 'top' }) + cmd.string(:order, 'Order of the results', choices: { 'Date' => 'date', 'Title' => 'title', '++\'s' => 'favs' }) + cmd.boolean(:reverse, 'Reverse ordering') + end + when :config + $bot.register_application_command(:config, 'Configure your outte++ usage', server_id: server_id) do |cmd| + cmd.string(:player, 'Specify your actual N++ / Steam player name so you can omit it in the future') + cmd.string(:nickname, 'Specify your alternative display name, will be used in future output') + cmd.string(:palette, 'Specify your default palette to use in screenshots, traces and animations') + cmd.string(:mappack, 'Specify your default mappack (MET to restore to vanilla)') + end + when :download + $bot.register_application_command(:download, 'Download a level, userlevel or replay', server_id: server_id) do |cmd| + cmd.string(:name, 'Name or ID of the level or userlevel', required: true) + cmd.integer(:rank, 'Rank of the score, to download the replay') + cmd.string(:board, 'The leaderboard mode (mappack replays only)', choices: { 'Speedrun' => 'sr', 'Highscore' => 'hs' }) + cmd.string(:mappack, 'The mappack the level belongs to') + cmd.boolean(:userlevel, 'Whether the level is a userlevel or not') + cmd.string(:author, 'Map author name or ID (userlevels only)') + end + when :screenshot + $bot.register_application_command(:screenshot, 'Generate a screenshot', server_id: server_id) do |cmd| + cmd.string(:name, 'Name or ID of the level, episode, story or userlevel', required: true) + cmd.string(:palette, 'Official palette to use') + cmd.string(:mappack, 'Search in this mappack') + cmd.boolean(:userlevel, 'Search in userlevels') + cmd.string(:author, 'Map author name or ID (userlevels only)') + end + when :video + $bot.register_application_command(:video, 'Show videos for a particular challenge', server_id: server_id) do |cmd| + cmd.string(:level, 'Name or ID of the level, episode or story', required: true) + cmd.string(:challenge, 'Challenge code (e.g. G++T--)') + cmd.string(:player, 'Code or name of the video author') + end + end + succ("%s %s command: %s" % [update ? 'Updated' : 'Registered', server_id ? 'guild' : 'global', cmd]) +rescue => e + lex(e, "Failed to %s %s command: %s" % [update ? 'update' : 'register', server_id ? 'guild' : 'global', cmd]) +end + +# Check that all commands are registered, and create any new ones +def register_commands() + server_id = TEST ? TEST_SERVER_ID : nil + commands = $bot.get_application_commands(server_id: server_id) + registered = commands.map(&:name).map(&:to_sym) + to_register = SUPPORTED_COMMANDS - DISABLED_COMMANDS + to_update = [] # To force an update, for development + + # Register or update all supported commands + to_register.each{ |cmd| + next if registered.include?(cmd) && !to_update.include?(cmd) + register_command(cmd, to_update.include?(cmd), server_id: server_id) + } + + # Unregister unsupported commands + commands.each{ |cmd| + next if to_register.include?(cmd.name.to_sym) + $bot.delete_application_command(cmd.id, server_id: server_id) + } + log("Registered commands") +end + +# Register Discordrb handlers for all supported application commands +def register_command_handlers(&handler) + SUPPORTED_COMMANDS.each{ |cmd| + $bot.application_command(cmd, {}, &handler) unless DISABLED_COMMANDS.include?(cmd) + } +end + +# Important notes for parsing interaction components: +# +# 1) We determine the origin of the interaction (the bot's source message) based +# on the first component of the button ID, or the first word of the message. +# Therefore, we have to format this first word (and, often, the first sentence) +# properly for the bot to parse it. +# +# 2) We use the custom_id of the component (button, select menu, modal) and of the +# component option (select menu option) to classify them and determine what +# they do. Therefore, they must ALL follow a specific pattern: +# +# IDs will be strings composed by a series of keywords separated by colons: +# The first keyword specifies the source of the interaction (in essence, what function). +# The second keyword specifies the category of the component (up to you). +# The third keyword specifies the specific component. +# Optionally, the fourth keyword specifies the method name. + +def respond_interaction_button(event) + keys = event.custom_id.to_s.split(':') # Component parameters + keys[0] = parse_message(event)[/\w+/i].to_s.downcase if keys[0] == 'button' + + # If it's a refresh button or a pager, we simply resend the exact same command, + # editing the old one + return send(keys[2], event) if keys[1] == 'refresh' && !!keys[2] + return send(keys[3], event, page: keys[2]) if keys[1] == 'nav' && !!keys[3] + + # Otherwise, distinguish depending on the source message + case keys[0].downcase + when 'admin' + case keys[1] + when 'logconf' + admin_logconf(event) + end + when 'aliases' + case keys[1] + when 'nav' + send_aliases(event, page: keys[2]) + end + when 'authors' + case keys[1] + when 'nav' + UserlevelAuthor.parse(event: event, page: keys[2]) + end + when 'browse' + case keys[1] + when 'nav' + send_userlevel_browse(event, page: keys[2]) + when 'play' + send_userlevel_cache(event) + end + when 'delete' + case keys[1] + when 'confirm' + delete_score(event, yes: keys[2] == 'yes') + end + when 'nav_scores' + case keys[1] + when 'h' + send_scores(event, offset: keys[2]) + when 'date' + send_scores(event, date_change: keys[2]) + end + when 'rankings' + case keys[1] + when 'nav' + send_rankings(event, page: keys[2]) + when 'ties' + send_rankings(event, ties: keys[2] == 'true') + when 'type' + send_rankings(event, type: keys[2]) + end + when 'results' + case keys[1] + when 'nav' + send_query(event, page: keys[2]) + end + when 'thumbnail' + send_thumbnail(event, keys[1]) + when 'videos' + edit = keys[0].downcase == keys[0] + send_videos(event, highscoreable: keys[1], challenge_code: keys[2], streamer_code: keys[3], edit: edit) + end +end + +def respond_interaction_menu(event) + keys = event.custom_id.to_s.split(':') # Component parameters + val = event.values.first # Identifier of selected option + + case keys[0] + when 'alias' # Select Menus for the alias list function + case keys[1] + when 'type' # Change type of alias (level, player) + send_aliases(event, type: val) + end + when 'browse' # Select Menus for the userlevel browse function + case keys[1] + when 'order' # Reorder userlevels (by title, author, date, favs) + send_userlevel_browse(event, order: val) + when 'tab' # Change tab (all, best, featured, top, hardest) + send_userlevel_browse(event, tab: val) + when 'mode' # Change mode (all, solo, coop, race) + send_userlevel_browse(event, mode: val) + end + when 'help' # Select Menus for the help function + case keys[1] + when 'sect' # Change section (Introduction, Commands, etc) + send_help(event, section: val) + end + when 'rank' # Select Menus for the rankings function + case keys[1] + when 'rtype' # Change rankings type (0th rankings, top20 rankings, etc) + send_rankings(event, rtype: val) + when 'tab' # Change highscoreable tab (all, si, s, su, sl, ss, ss2, st) + send_rankings(event, tab: val) + end + when 'speedrun' # Select Menus for the Speedrun API function + if keys[1] =~ /^var-(.+)$/ + send_speedruns(event, variable: $1, value: val) + else + send_speedruns(event, keys[1].to_sym => val) + end + when 'videos' + send_videos(event, highscoreable: keys[1], challenge_code: keys[2], streamer_code: val) + end +end + +def respond_interaction_modal(event) + keys = event.custom_id.to_s.split(':') # Component parameters + + case keys[0] + when 'admin' + case keys[1] + when 'logconf' + modal_logconf(event) + end + when 'identify' + modal_identify(event, name: event.value('name')) + end +end + +def respond_application_command(event) + return if event.interaction.type != 2 # Only respond to application commands + opt = event.options + + case event.command_name + when :browse + send_userlevel_browse(event, **opt.symbolize_keys) + when :config + send_config(event, **opt.symbolize_keys) + when :download + if opt['userlevel'] || opt['author'] + if opt['rank'] + send_userlevel_demo_download(event, **opt.symbolize_keys) + else + send_userlevel_download(event, **opt.symbolize_keys) + end + else + if opt['rank'] + send_demo_download(event, **opt.symbolize_keys) + else + send_download(event, **opt.symbolize_keys) + end + end + when :screenshot + if opt['userlevel'] || opt['author'] + send_userlevel_screenshot(event, **opt.symbolize_keys) + else + send_screenshot(event, **opt.symbolize_keys) + end + when :video + send_videos(event, highscoreable: opt['level'], challenge_code: opt['challenge'], streamer_code: opt['player']) + else + perror("Unrecognized application command.") + end +end + +# Done loading file +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) diff --git a/src/io.rb b/src/io.rb new file mode 100644 index 00000000..77070e76 --- /dev/null +++ b/src/io.rb @@ -0,0 +1,1571 @@ +# This file contains the functions that handle a lot of the specific I/O, +# like parsing (player names, level names...) and formatting (level names, +# sending files...) + +$load_time = Time.now + +require 'active_support/core_ext/integer/inflections' # ordinalize +require 'active_support/core_ext/numeric/time' # 1.hour, 3.minutes.ago, etc + +# TODO: Adapt for V2-component-based messages +# Fetch message from an event. Depending on the event that was triggered, this +# may accessed and handled in a different way. +def parse_message(event, clean: true, embed: false) + # Integrity checks + is_message = event.is_a?(Discordrb::Events::MessageEvent) + is_component = event.is_a?(Discordrb::Events::ComponentEvent) + is_reaction = event.is_a?(Discordrb::Events::ReactionEvent) + is_command = event.is_a?(Discordrb::Events::ApplicationCommandEvent) + return '' if is_command + raise "Cannot parse message from a #{event.class.to_s}." if !is_message && !is_component && !is_reaction + + # Extract message + return '' if !event.message + msg = event.message.content + msg << "\n" << event.message.embeds.map{ |e| e.title }.join("\n") if embed + msg = msg.gsub(/```.*```/m, '') if !is_message && clean # Exclude text blocks + + msg +rescue => e + lex(e, 'Failed to parse message content') + '' +end + +def is_dm(event) + event.channel.type == channel_type(:dm) +end + +# This is used mainly for page navigation. We determine the current page, +# and we also determine whether we need to add an offset to it (to navigate) +# or reset it (when a different component, e.g. a select menu, was activated). +def parse_page(msg, offset = 0, reset = false, components = nil, default: 1) + return 1 if reset + page = nil + components.to_a.each{ |row| + row.each{ |component| + page = component.label.to_s[/\d+/i].to_i if component.custom_id.to_s =~ /nav:page/ + } + } + (page || msg[/page:?[\s\*]*(\d+)/i, 1] || default).to_i + offset.to_i +rescue + 1 +end + +# Get an emoji from a button given the label +def get_emoji(event, label) + emoji = nil + event.message.components.each{ |row| + row.each{ |c| + emoji = c.emoji.name if c.custom_id == label + } + } + #event.message.components.first.to_a[2].emoji.name + emoji +rescue + nil +end + +# Given an amount and a page number, will make sure that it is a valid +# page number (given the page size), or clamp it otherwise, as well as +# find the offset index where the page begins. +def compute_pages(count = 1, page = 1, pagesize = PAGE_SIZE) + pages = [(count.to_f / pagesize).ceil, 1].max + page = page > pages ? pages : (page < 1 ? 1 : page) + offset = (page - 1) * pagesize + { page: page, pages: pages, offset: offset } +end + +# Shortcut for parsing + computing pages, since we have to do that often +def get_paging(event, page, count, size = 1) + msg = parse_message(event) + page = parse_page(msg, page, false, event.message.components) + pag = compute_pages(count, page, size) +end + +# Given a list, page through it by sending messages to Discord with only +# a page of it (of given offset and length), together with buttons to +# navigate the pages interactively. +def pager(event, page, header: "Results", list: [], size: PAGE_SIZE, rails: false, pluck: [], func: nil) + # Format header + page = parse_page(parse_message(event), page, false, event.message.components) + pag = compute_pages(list.count, page) + header << " (#{list.count} matches):" + + # Format list + list = rails ? list.offset(pag[:offset]).limit(size) : list[pag[:offset], size] + list = list.pluck(*pluck) if rails && !pluck.empty? + list.map!{ |e| yield(e) } if block_given? + list = list.join("\n") + header << format_block(list) + + # Send message with buttons + view = Discordrb::Webhooks::View.new + interaction_add_button_navigation(view, pag[:page], pag[:pages], func: func) unless pag[:pages] == 1 + send_message(event, content: header, components: view) + perror +end + +# Parse game mode from string. If explicit is set, one of the 3 modes must be +# set (defaulting to 'solo'), otherwise, it defaults to 'all', or nil if +# 'null' is true +def parse_mode(msg, explicit = false, null = false) + !!msg[/\brace\b/i] ? 'race' : (!!msg[/\bcoop\b/i] ? 'coop' : (explicit ? 'solo' : (!!msg[/\bsolo\b/i] ? 'solo' : (null ? nil : 'all')))) +end + +# Parses a string looking for a score in the usual N++ 3-decimal floating point format +def parse_score(str) + score = str.tr('_', '-')[/(\s|^)-?([1-9]\d*|0)(\.\d{1,3})?(\s|$)/] + perror("Couldn't find / understand the score") if score.nil? + perror("The score is incorrect") if !verify_score(score.to_f) + score.to_f +end + +# Parse the highscoreable type (Level/Episode/Story) from a message +def parse_type( + msg, # Text to parse type from + type: nil, # Initial type we might send + multiple: false, # Whether to allow multiple types and return and array + initial: false, # Whether this is the first call to the function + default: nil # Default types when none is found + ) + # Sanitize default type + if !['level', 'episode', 'story'].include?(default.to_s.downcase) + default = nil + else + default = default.to_s.capitalize.constantize + end + + # First, parse the parameter we sent + type = type.to_s.capitalize.constantize unless type.nil? + return type if !multiple && ['level', 'episode', 'story'].include?(type.to_s.downcase) + + # If it's not correct, then parse message + ret = [] + multiple ? ret << Level : (return Level) if !!msg[/\blevels?\b/i] || !!msg[/lotd/i] + multiple ? ret << Episode : (return Episode) if !!msg[/\bepisodes?\b/i] || !!msg[/eotw/i] + multiple ? ret << Story : (return Story) if !!msg[/\bstory\b/i] || !!msg[/\bstories\b/i] || !!msg[/\bcolumn\b/i] || !!msg[/\bhard\s*core\b/i] || !!msg[/\bhc\b/i] || !!msg[/cotm/i] + + if multiple + # If still empty (and initial), push default types + if initial && ret.empty? + default.nil? ? ret.push(*DEFAULT_TYPES.map(&:constantize)) : ret.push(default) + end + + # If "overall" is matched, push default types too + if !!msg[/\boverall\b/i] + ret.push(*DEFAULT_TYPES.map(&:constantize)) + end + + # Also, toggle the type we sent (add or remove) (see rankings navigation) + ret.include?(type) ? ret.delete(type) : ret.push(type) if !type.nil? + ret.uniq! + else + # If not multiple, we return either the default we sent, or nil (type not found) + ret = default + end + + ret +end + +def parse_alias_type(msg, type = nil) + ['level', 'player'].include?(type) ? type : (!!msg[/player/i] ? 'player' : 'level') +end + +# Transform tab into [SI, S, SU, SL, ?, !, ?! & !?] format +def normalize_tab(tab) + format_tab(parse_tabs(tab)[0]) +end + +# Transform tab into [SI, S, SU, SL, SS, SS2, ST] format +def formalize_tab(tab) + parse_tabs(tab)[0].to_s +end + +# Normalize the name (ID) of a highscoreable, by: +# 1) Adding missing dashes +# 2) Adding padding 0's +# 3) Capitalizing all letters +def normalize_name(name) + return name.to_s if !name.is_a?(String) && !name.is_a?(MatchData) + name = name.captures.compact.join('-') if name.is_a?(MatchData) + name.split('-').map { |s| s[/\A[0-9]\Z/].nil? ? s : "0#{s}" }.join('-').upcase +rescue + name.to_s +end + +# The following are 2 auxiliary functions for the next ones +# Parse a single player when a name has been provided +def parse_player_explicit(name, userlevel = false, rais: true) + return nil if name.strip.empty? + + # Check if player with this name exists + player = (userlevel ? UserlevelPlayer : Player).where.not(metanet_id: nil).find_by(name: name) + return player if player + + # Check if player with this alias exists + if !userlevel + player = Player.joins('INNER JOIN player_aliases ON players.id = player_aliases.player_id') + .where(["player_aliases.alias = ?", name]) + .take + return player if player + end + + # No results + perror("#{name} doesn't have any high scores! Either you misspelled the name / alias, or they're exceptionally bad...") if rais + nil +end + +# Parse a single player when a username has been provided +def parse_player_implicit(event, userlevel = false) + username = event.user.name + + # Check if the user is identified + user = User.find_by(discord_id: event.user.id) + return user.player(userlevel: userlevel) if user && user.player + + # Check if player with same username exists + player = (userlevel ? UserlevelPlayer : Player).where.not(metanet_id: nil).find_by(name: username) + return player if player + + # No results + perror("I couldn't find a player with your username! Have you identified yourself (with '@outte++ my name is ')?") +end + +# Fetch a Player or UserlevelPlayer from a text string. +# Optionally may also infer the player from the username. +# TODO: Change args to kwargs +def parse_player( + event, # Originating event (contains text, user...) + userlevel = false, # Whether to search in for userlevel players or regular ones + explicit = false, # Only parse explicit names, without inferring from username + enforce = false, # Even more, raise exception if no explicit name found + implicit = false, # The opposite, only infer from username + third = false, # Allow 3rd person specification (e.g. "is xela" rather than "for xela") + flag: nil # For special commands, flag that will contain the player name + ) + msg = parse_message(event).gsub(/"/, '') + p = flag ? parse_flags(msg)[flag.to_sym].to_s : msg[/(for|of#{third ? '|is' : ''}) (.*)[\.\?]?/i, 2] + + return parse_player_implicit(event, userlevel) if implicit + return parse_player_explicit(p, userlevel) if p + return parse_player_implicit(event, userlevel) if !explicit + perror("You need to specify a player name.") if enforce + + nil +end + +# Parse a pair of players. The user may provide 0, 1 or 2 names in different +# formats, so we act accordingly and reuse the previous method. +def parse_players(event, userlevel = false) + msg = parse_message(event) + p = parse_term(msg, global: true) + case p.size + when 0 + p1 = parse_player(event, userlevel, true, true, false) + p2 = parse_player(event, userlevel, false, false, true) + when 1 + p1 = parse_player_explicit(p[0], userlevel) + p2 = parse_player(event, userlevel, false, false, true) + when 2 + p1 = parse_player_explicit(p[0], userlevel) + p2 = parse_player_explicit(p[1], userlevel) + else + perror("Too many players! Please enter either 1 or 2.") + end + [p1, p2] +end + +def parse_many_players(msg, userlevel = false) + msg = msg[/without (.*)/i, 1] || "" + players = msg.split(/,|\band\b|\bor\b/i).flatten.map(&:strip).reject(&:empty?) + players.map{ |name| parse_player_explicit(name) } +end + +# Parse a User object from a Discord::User object +def parse_user(discord_user) + user = User.find_or_create_by(discord_id: discord_user.id) + user.update(name: discord_user.name) + user +end + +# The username can include the tag after a hash +def parse_discord_user(msg) + user = msg[NAME_PATTERN, 2].downcase + perror("You need to provide a user.") if user.nil? + + parts = user.split('#') + users = find_users(name: parts[0], tag: parts[1]) + case users.size + when 0 + perror("No user named #{user} found in the server.") + when 1 + return users.first + else + list = users.map{ |u| [u.name, u.tag.to_i, u.joined_at] } + .sort_by{ |u| [u[2], u[1]] } + .map{ |u| u[0] + '#' + u[1].to_s.ljust(4) + ' ' + u[2].strftime('%Y/%m/%d') } + .join("\n") + perror("Multiple users named #{parts[0]} found, please include the numerical tag as well:\n#{format_block(list)}") + end +end + +def parse_video_author(msg) + return msg[/by (.*)[\.\?]?\Z/i, 1] +end + +def parse_challenge(msg) + return msg[/([GTOCE][+-][+-])+/] +end + +def parse_challenge_code(msg) + return msg[/([!?]+)[^-]/, 1] +end + +def parse_videos(event) + msg = parse_message(event) + author = parse_video_author(msg) + msg = msg.chomp(" by " + author.to_s) + highscoreable = parse_highscoreable(event) + challenge = parse_challenge(msg) + code = parse_challenge_code(msg) + videos = highscoreable.videos + + videos = videos.where('lower(author) = ? or lower(author_tag) = ?', author.downcase, author.downcase) unless author.nil? + videos = videos.where(challenge: challenge) unless challenge.nil? + videos = videos.where(challenge_code: code) unless code.nil? + + perror("That level doesn't have any videos!") if highscoreable.videos.empty? + perror("I couldn't find any videos matching that request! Are you looking for one of these videos?\n#{format_block(highscoreable.videos.map(&:format_description).join("\n"))}") if videos.empty? + return videos +end + +def parse_steam_id(msg) + id = msg[/is (.*)[\.\?]?/i, 1] + perror("I couldn't figure out what your Steam ID was! You need to send a message in the format 'my steam id is '.") if id.nil? + perror("Your Steam ID needs to be numerical! #{id} is not valid.") if id !~ /\A\d+\Z/ + return id +end + +# Parse a highscoreable by ID (e.g. SU-D-17-03) with a specific set of parameters +# Optionally provide a user for specific preferences +def parse_h_by_id_once( + msg, # Text to parse the ID from + user = nil, # User to parse specific preferences + channel = nil, # Discord channel + matches = [], # Array to add the _normalized_ ID if found + type: Level, # Type of highscoreable ID + vanilla: true, # Whether to look for mappack IDs or regular ones + mappack: nil, # Search in mappacks (truthy = all) + dashed: true # Strict dashed IDs vs optionally-dashed IDs + ) + # Parse selected pattern + packing = vanilla ? :vanilla : :mappack + dashing = dashed ? :dashed : :dashless + pattern = ID_PATTERNS[type.to_s][packing][dashing] + + # Match pattern + match = msg.match(pattern) + return ['', []] if match.nil? + + # Format name + pack = parse_mappack(msg, user, channel) + str = normalize_name(match) + matches << str + code = pack ? pack.code.upcase : 'MET' + str.prepend(code + '-') if vanilla && mappack + + # Find highscoreable + if mappack + res = type.mappack + .joins('INNER JOIN `mappacks` ON `mappacks`.`id` = `mappack_id`') + .where("`mappacks`.`public` = 1") + .where(mappack.is_a?(Mappack) ? { mappack: mappack } : nil) + .find_by(name: str) + else + res = type.find_by(name: str) + end + res ? ["Single match found for #{match}", [res]] : ['', []] +rescue => e + lex(e, 'Failed to parse highscoreable by ID') + ['', []] +end + +# Parse a highscoreable based on the ID: +# 1) First we check dashed versions for levels and episodes +# 2) Then dashless for levels and episodes +# (Thus SU-A-10 will be parsed as the episode, not as the level SU-A-1-0 +# missing some dashes, and SUA15 will be parsed as the episode SU-A-15, +# even though it also fits the dashless level SU-A-1-5, because no such +# level exists). +# 3) Then parse columns (no ambiguity as they don't have row letter). +def parse_highscoreable_by_id(msg, user = nil, channel = nil, mappack: nil, type: nil, silent: false) + ret = ['', []] + + # Mappack variants, if allowed + matches = [] + if mappack + ret = parse_h_by_id_once(msg, user, channel, matches, type: Level, mappack: mappack, vanilla: false, dashed: true) if ret[1].empty? && (!type || type == Level) + ret = parse_h_by_id_once(msg, user, channel, matches, type: Episode, mappack: mappack, vanilla: false, dashed: true) if ret[1].empty? && (!type || type == Episode) + ret = parse_h_by_id_once(msg, user, channel, matches, type: Level, mappack: mappack, vanilla: false, dashed: false) if ret[1].empty? && (!type || type == Level) + ret = parse_h_by_id_once(msg, user, channel, matches, type: Episode, mappack: mappack, vanilla: false, dashed: false) if ret[1].empty? && (!type || type == Episode) + ret = parse_h_by_id_once(msg, user, channel, matches, type: Story, mappack: mappack, vanilla: false, dashed: true) if ret[1].empty? && (!type || type == Story) + + # If there were ID matches, but they didn't exist, raise + if ret[1].empty? && matches.size > 0 + ids = matches.uniq.map{ |m| verbatim(m) }.to_sentence(two_words_connector: ' or ', last_word_connector: ', or ') + perror("There is no mappack level/episode/story by the #{'ID'.pluralize(matches.size)}: #{ids}.") unless silent + end + end + + # Vanilla variants + matches = [] + ret = parse_h_by_id_once(msg, user, channel, matches, type: Level, mappack: mappack, vanilla: true, dashed: true) if ret[1].empty? && (!type || type == Level) + ret = parse_h_by_id_once(msg, user, channel, matches, type: Episode, mappack: mappack, vanilla: true, dashed: true) if ret[1].empty? && (!type || type == Episode) + ret = parse_h_by_id_once(msg, user, channel, matches, type: Level, mappack: mappack, vanilla: true, dashed: false) if ret[1].empty? && (!type || type == Level) + ret = parse_h_by_id_once(msg, user, channel, matches, type: Episode, mappack: mappack, vanilla: true, dashed: false) if ret[1].empty? && (!type || type == Episode) + ret = parse_h_by_id_once(msg, user, channel, matches, type: Story, mappack: mappack, vanilla: true, dashed: true) if ret[1].empty? && (!type || type == Story) + + # If there were ID matches, but they didn't exist, raise + if ret[1].empty? && matches.size > 0 + ids = matches.uniq.map{ |m| verbatim(m) }.to_sentence(two_words_connector: ' or ', last_word_connector: ', or ') + perror("There is no level/episode/story by the #{'ID'.pluralize(matches.size)}: #{ids}.") unless silent + end + + ret +rescue + ['', []] +end + +# Parse a highscoreable based on a "code" (e.g. lotd/eotw/cotm) +def parse_highscoreable_by_code(msg, user = nil, channel = nil, mappack: nil, type: nil) + # Parse type + lotd = !!msg[/(level of the day|lotd)/i] && (!type || type == Level) + eotw = !!msg[/(episode of the week|eotw)/i] && (!type || type == Episode) + cotm = !!msg[/(column of the month|cotm)/i] && (!type || type == Story) + return ['', []] if !lotd && !eotw && !cotm + + klass = lotd ? Level : eotw ? Episode : Story + type = lotd ? 'level of the day' : eotw ? 'episode of the week' : 'column of the month' + + # Parse mappack (manually specified and default one) + pack = mappack ? mappack.is_a?(Mappack) ? mappack : parse_mappack(msg, user, channel) : nil + ctp = pack && pack.code.upcase == 'CTP' + type.prepend(pack.code.upcase + ' ') unless pack.code.upcase == 'MET' if pack + perror("There is no #{type}.") if pack && !pack.lotd + + # Fetch lotd/eotw/cotm + ret = GlobalProperty.get_current(klass, ctp) + perror("There is no current #{type}.") if ret.nil? + + ret ? ["Single match found", [ret]] : ['', []] +rescue + ['', []] +end + +# Parse a highscoreable based on the name +# 'mappack' specifies whether searching for mappack highscoreables is allowed if +# truthy, it can also be a Mappack itself +def parse_highscoreable_by_name(msg, user = nil, channel = nil, mappack: nil, type: nil) + # Only levels have names + return ['', []] if name.empty? if type && type != Level + + # Extract elements from message + pack = mappack ? mappack.is_a?(Mappack) ? mappack : parse_mappack(msg, user, channel) : nil + klass = pack && pack.id != 0 ? MappackLevel.where(mappack: pack) : Level + pack = pack && pack.id != 0 ? pack.code.upcase + ' ' : 'MET ' + name = msg.split("\n")[0][/(?:for|of) (.*)/i, 1].tr('"`:', '').strip + return ['', []] if name.empty? + + # Exact name match + ret = ['', klass.where_like('longname', name, partial: false).to_a] + ret[0] = "Single #{pack}name match found for #{verbatim(name)}:" if ret[1].size == 1 + ret[0] = "Multiple #{pack}name matches found for #{verbatim(name)}" if ret[1].size > 1 + return ret if !ret[1].empty? + + # Exact alias match + query = klass.joins("INNER JOIN level_aliases ON #{klass.table_name}.id = level_id") + ret = ['', query.where_like('alias', name, partial: false).to_a] + ret[0] = "Single #{pack}alias match found for #{verbatim(name)}:" if ret[1].size == 1 + ret[0] = "Multiple #{pack}alias matches found for #{verbatim(name)}:" if ret[1].size > 1 + return ret if !ret[1].empty? + + # Partial name match + ret = ['', klass.where_like('longname', name, partial: true).to_a] + ret[0] = "Single partial #{pack}name match found for #{verbatim(name)}:" if ret[1].size == 1 + ret[0] = "Multiple partial #{pack}name matches found for #{verbatim(name)}:" if ret[1].size > 1 + return ret if !ret[1].empty? + + # Closest matches + list = klass.all.pluck(:name, :longname) + matches = string_distance_list_mixed(name, list) + ret = [ + "No #{pack}matches found for #{verbatim(name)}. Did you mean...", + matches.map{ |m| klass.find_by(name: m[0]) } + ] + return ret if !ret[1].empty? + + perror("No #{pack}matches found for #{verbatim(name)}.") +rescue + ['', []] +end + +# TODO: +# - Add QueryResult class that handles keeping track of the results, +# formatting them, etc. Adjust functions and comments accordingly. + +# Parse a highscoreable (Level, Episode, Story, or the corresponding Mappack ones) +# Returns it if a single result is found, or prints the list if multiple (see params). +def parse_highscoreable( + event, # Event whose content contains the highscoreable to parse + msg: nil, # String to parse, instead of the event content + list: false, # Force to print list, even if there's a single match + mappack: nil, # Include mappack highscoreables (truthy = all mappacks) + page: 0, # Page offset when navigating list of matches + vanilla: true, # Don't return Metanet highscoreables as MappackHighscoreable + map: false, # Force Metanet highscoreables to MappackHighscoreable + type: nil, # Force a specific type + silent: false, # If no results, return quietly instead of throwing + parse_id: true, # Parse by ID + parse_code: true, # Parse by code (lotd / eotw / cotm) + parse_name: true # Parse by level name + ) + + # Parse message + msg.prepend('for ') if msg + msg ||= parse_message(event, embed: true) + empty = msg.to_s.strip.empty? + return nil if empty && silent + perror("Couldn't find the level, episode or story you were looking for :(") if empty + + # Parse other useful elements (for user defaults) + user = parse_user(event.user) + channel = event.channel + pack = mappack ? mappack.is_a?(Mappack) ? mappack : parse_mappack(msg, user, channel) : nil + ret = ['', []] + + # Search for highscoreable according to different criteria + ret = parse_highscoreable_by_id(msg, user, channel, mappack: mappack, type: type) if parse_id + ret = parse_highscoreable_by_code(msg, user, channel, mappack: mappack, type: type) if parse_code && ret[1].empty? + ret = parse_highscoreable_by_name(msg, user, channel, mappack: mappack, type: type) if parse_name && ret[1].empty? + + # No results + pack_str = pack && pack.id != 0 ? pack.code.upcase + ' ' : '' + if ret[1].empty? + return nil if silent + perror("No #{pack_str}results found.") if list + perror("Couldn't find the #{pack_str}level, episode or story you were looking for :(") + end + + # Transform to vanilla or map if appropriate + ret[1].map!{ |m| m.vanilla } if vanilla + ret[1].map!{ |m| m.map } if map + + # Return single highscoreable or print list of results + no_list = (!list && ret[1].size == 1) || ret[1].any?{ |h| !h.is_level? } + return ret[1].first if no_list + format_level_matches(event, msg, page, ret, 'results') +rescue => e + lex(e, 'Failed to parse highscoreable') + nil +end + +def parse_rank(msg) + rank = msg[/top\s*([0-9][0-9]?)/i, 1] + rank ? rank.to_i.clamp(1, 20) : nil +end + +def parse_bottom_rank(msg) + rank = msg[/bottom\s*([0-9][0-9]?)/i, 1] + rank ? (20 - rank.to_i).clamp(0, 19) : nil +end + +def parse_ranks(msg, clamp = 20) + ranks = msg.scan(/(?<=\s|^|$)\d{1,3}(?=\s|^|$)/).map(&:to_i) + ranks.map!{ |r| r.clamp(0, clamp - 1) } unless clamp <= 0 + ranks.uniq! + ranks.empty? ? [0] : ranks +end + +def parse_nav(msg) + !!msg[/\bnav((igat)((e)|(ing)))?\b/i] +end + +def parse_offline(msg) + !!msg[/\boffline\b/i] +end + +def parse_maxed(msg) + !!msg[/\bmaxed\b/i] +end + +def parse_maxable(msg) + !!msg[/\bmaxable\b/i] +end + +# Parse a mappack explicitly from a message, or implicitly from the user or +# channel defaults +# event - Parse everything from the Discordrb event +# explicit - Disables implicit parsing +# vanilla - Converts Metanet mappack to nil (i.e. no mappack) +# priv - Also parse mappacks with private visibility +def parse_mappack(msg = nil, user = nil, channel = nil, event: nil, explicit: false, vanilla: true, priv: false) + # Fetch params + if event + msg = parse_message(event) + user = parse_user(event.user) + channel = event.channel + end + + # Init params + text = msg && !msg.strip.empty? + term = parse_term(msg, quoted: [], final: ['for']) + mappack = nil + klass = priv ? Mappack : Mappack.where(public: true) + + # Parse mappack explicitly in different ways + mappack = klass.find_by(name: msg.strip[/\w+/i]) if text + mappack = klass.find_by(name: term) if text && !mappack + mappack = klass.find_by(code: msg.scan(/\b[A-Z0-9]{3}\b/i)) if text && !mappack + + # Parse mappack implicitly + mappack = default_mappack(user, channel) if !mappack && !explicit + + # Convert to vanilla if Metanet + mappack = nil if vanilla && mappack && mappack.id == 0 + + mappack +rescue + nil +end + +# We parse a complex variety of ranges here, from individual ranks, to tops, +# bottoms, intermediate ranges, etc. +# 'full' means that if no range has been explicitly provided, then we default +# to 0th-19th, otherwise we default to 0th-1st. +# 'complete' means we take all scores, not just the top20 +def parse_range(msg, full = false, complete = false) + # Parse "topX" and "bottomX" + rank = parse_rank(msg) || 20 + bott = parse_bottom_rank(msg) || 0 + # Parse up to 2 individual ranks (e.g. 2nd, 7th...) + inds = msg.scan(/\b[0-9][0-9]?(?:st|nd|rd|th)\b/i) + .map{ |r| r.to_i.clamp(0, 20) } + .uniq + .sort + .take(2) + # If there's only 1 individual rank, the interval has width 1 + inds.push(inds.first) if inds.size == 1 + # Figure out if we need to default + dflt = parse_rank(msg).nil? && parse_bottom_rank(msg).nil? + + # If no range is provided, default to 0th count + if dflt + if complete + bott = 0 + rank = nil + elsif full + bott = 0 + rank = 20 + else + bott = 0 + rank = 1 + end + end + + # If an individual rank is provided, the range has width 1 + if !inds.empty? + bott = inds[0].clamp(0, 19) + rank = (inds[1] + 1).clamp(1, 20) + end + + [bott, rank, !bott || !rank || bott < rank] +end + +# Parse a message for tabs +# If 'tab' is passed, we're in a select menu, and we +# include either one tab or none (all) +def parse_tabs(msg, tab = nil) + if !tab.nil? + if ['si', 's', 'su', 'sl', 'ss', 'ss2', 'st'].include?(tab) + [tab.upcase.to_sym] + else + [] + end + else + ret = [] + ret << :SI if msg =~ /\b(intro|SI)\b/i + ret << :S if msg =~ /(\b|\A|\s)(N\+\+|S|solo)(\b|\Z|\s)/i + ret << :SU if msg =~ /\b(SU|UE|ultimate)\b/i + ret << :SL if msg =~ /\b(legacy|SL)\b/i + ret << :SS if msg =~ /(\A|\s)(secret|\?)(\Z|\s)/i + ret << :SS2 if msg =~ /(\A|\s)(ultimate secret|!)(\Z|\s)/i + ret << :ST if msg =~ /(\A|\s)(\?!|!\?|ten\+\+)(\Z|\s)/i + ret.size == 7 ? [] : ret + end +end + +# Ranking type +def parse_rtype(msg) + if !!msg[/average/i] && !!msg[/point/i] + 'average_point' + elsif !!msg[/average/i] && !!msg[/lead/i] + 'average_top1_lead' + elsif !!msg[/average/i] + 'average_rank' + elsif !!msg[/point/i] + 'point' + elsif !!msg[/\bscore/i] + 'score' + elsif parse_singular(msg) == 1 + 'singular_top1' + elsif parse_singular(msg) == -1 + 'plural_top1' + elsif !!msg[/tied/i] + 'tied_top1' + elsif parse_maxed(msg) + 'maxed' + elsif parse_maxable(msg) + 'maxable' + elsif parse_cool(msg) + 'cool' + elsif parse_star(msg, true, true) + 'star' + elsif parse_gp(msg) + 'G++' + elsif parse_gm(msg) + 'G--' + else + 'top' + end +end + +# Complete rtype with additional rank info +def fix_rtype(rtype, rank) + rtype += (rank || 1).to_s if rtype == 'top' + rtype +end + +# Regex to determine the field to order by in userlevel searches +# Order may be inverted by specifying a "-" before, or a "desc" after, or both +# It then modifies the original message to remove the order query part +def parse_order(msg, order = nil) + regex = /(order|sort)(ed)?\s*by\s*((\w|\+|-)*)\s*(asc|desc)?/i + order = order || msg[regex, 3] || "" + desc = msg[regex, 5] == "desc" + reverse = (order.strip[/\A-*/i].length % 2 == 1) ^ desc + order.delete!("-") + msg.remove!(regex) + { msg: msg, order: order, reverse: reverse } +end + +# Parse a quoted term from a string. Features: +# - Can handle quotes themselves, if they're escaped +# - An array of prefixes to match before the quoted term (can also be a single string) +# - Robust, never raises exceptions, always returns a string +# - Also supports a mode to match everything after the prefix, or even both +# (in which case, the quoted match takes preference) +# 'quoted' contains the array of prefixes for quoted terms, can be empty +# 'final' contains the array of prefixes for general matches, cannot be empty +# 'global' returns an array with all matches, rather than only the first +# (only valid for quoted parses) +# 'remove' returns an array with the match, and the msg with the match removed +def parse_term(str, quoted: nil, final: nil, global: false, remove: false) + return (remove ? ['', str] : '') if !str.is_a?(String) || str.strip.empty? + final = nil if global + prefix = [ + regexize_words(quoted), + regexize_words(final) + ] + regex = [ + /#{prefix[0]}\s*"((?:(?:\\.)|[^\\"])*)"/i, + /#{prefix[1]}\s+(.*)/i + ] + str.gsub!(/["“”`´]/, '"') + if global + matches = str.scan(regex[0]).map(&:first) + return (remove ? [matches, str.remove(regex[0])] : matches) + end + match = [ + str[regex[0], 1], + (str[regex[1], 1] unless prefix[1].empty?).to_s.strip + ] + c = !quoted.nil? && !final.nil? ? (!match[0].nil? ? 0 : 1) : (quoted.nil? && !final.nil? ? 1 : 0) + remove ? [match[c].to_s, str.remove(regex[c])] : match[c].to_s +rescue + remove ? ['', str] : '' +end + +# Parses a term: +# 1) First quoted, and if found, returned +# 2) Then unquoted, and if found: +# 2.1) If it's a number, it's assumed to be an ID, casted to int and returned +# 2.2) Otherwise, assumed to be a string, returned +def parse_string_or_id(msg, quoted: nil, final: nil, remove: false) + name, msg = parse_term(msg, quoted: quoted, remove: true) + return (remove ? [name, msg] : name) if !name.empty? + name, msg = parse_term(msg, final: final, remove: true) + name = name.strip.to_i if is_num(name) + return (remove ? [name, msg] : name) +rescue + remove ? ['', msg] : '' +end + +# Parse userlevel author from a message. Might return a string if a name is found, +# or an integer if an ID is found. +def parse_userlevel_author(msg, remove: false) + name, msg = parse_term(msg, quoted: ['author id'], remove: true) + if !name.empty? && is_num(name) + name = name.strip.to_i + else + name, msg = parse_string_or_id(msg, quoted: ['by', 'author'], final: ['by'], remove: true) + name.strip! if name.is_a?(String) + end + remove ? [name, msg] : name +rescue + remove ? ['', msg] : '' +end + +# Parse userlevel title from a message. Might return a string if a name is found, +# or an integer if an ID is found. +# Also, if 'full' is true, when a name is found the entire msg will be considered the name +def parse_userlevel_title(msg, remove: false, full: true) + name, msg = parse_string_or_id(msg, quoted: ['for', 'of', 'title'], final: ['for', 'of'], remove: true) + name.strip! if name.is_a?(String) + if name.is_a?(String) && name.empty? && full + name, msg = msg, '' + name = name.strip.to_i if is_num(name) + end + remove ? [name, msg] : name +rescue + remove ? ['', msg] : '' +end + +# Same as above, but using both prepositions +def parse_userlevel_both(msg, remove: false, full: false) + name, msg = parse_string_or_id(msg, quoted: ['for', 'by', 'author'], final: ['for', 'by'], remove: true) + name.strip! if name.is_a?(String) + if name.is_a?(String) && name.empty? && full + name, msg = msg, '' + name = name.strip.to_i if is_num(name) + end + remove ? [name, msg] : name +rescue + remove ? ['', msg] : '' +end + +# TODO: Adapt all uses of "author" in userlevels.rb to the new system, both when +# using the author directly, as well as when calling one of the functions from io.rb +# that have changed (parse_title_and_author, parse_userlevel, etc). + +# The following function is supposed to modify the message! +# +# Supports querying for both titles and author names. If both are present, at +# least one should go in quotes, to distinguish between them (it will still work +# without quotes if the author comes before the name). The one without +# quotes must go at the end, so that everything after the particle is taken to +# be the title/author name. +# +# 'full' allows for the title to default to the whole msg if no prefix is found +def parse_title_and_author(msg, full = true) + author, msg = parse_userlevel_author(msg, remove: true) + title, msg = parse_userlevel_title(msg, remove: true, full: full) # Keep last! (title can be whole msg if no "for" is found) + [title, author, msg] +end + +def parse_author(event, rais = true) + UserlevelAuthor.parse(parse_userlevel_author(parse_message(event)), event: event) +rescue + raise if rais + nil +end + +# Parse a userlevel from a message by looking for a title or an ID, as well as +# an author or author ID, optionally. +def parse_userlevel(event, userlevel = nil, msg: nil) + # --- PARSE message elements + return { + query: userlevel, + msg: '', + count: 1, + title: userlevel.title.to_s, + author: userlevel.author.name.to_s + } if userlevel + + msg ||= parse_message(event) + title, author, msg = parse_title_and_author(msg, true) + author = UserlevelAuthor.parse(author, event: event) + + # --- FETCH userlevel(s) + query = Userlevel + err = "" + count = 0 + + # TODO: Use this code for the userlevel_browse function too, perhaps abstract + + if title.is_a?(Integer) + query = Userlevel.where(id: title) + err = "No userlevel with ID #{verbatim(title)} found." + else + errs = [] + if !title.empty? + query = query.where_like('title', title[0...128]) + errs << "with title #{verbatim(title[0...128])}" + end + if !author.nil? + query = query.where(author_id: author.id) + errs << "by author #{verbatim(author.name)}" + end + err = "No userlevel #{errs.join(' ')} found." + end + + # --- Prepare return depending on map count + ret = "" + count = query.count + author = author.name if author.is_a?(UserlevelAuthor) + case count + when 0 + ret = err + when 1 + query = query.first + else + ret = "Multiple matching maps found. Please refine terms or use the userlevel ID:" + end + { query: query, msg: ret, count: count, title: title.to_s, author: author.to_s } +end + +# The palette may or may not be quoted, but it MUST go at the end of the command +# if it's not quoted. Only look in 'pal' if not nil. 'fallback' will default +# to the default palette if no good matches, otherwise exception. +def parse_palette(event, dflt = Palette::DEFAULT, pal: nil, fallback: true, msg: nil) + msg.prepend('palette ') if msg + msg ||= parse_message(event) + err = "" + pal.strip! if !pal.nil? + + # Parse message for explicit palette specification + pal, msg = parse_term(msg, quoted: ['palette'], final: ['palette'], remove: true) if pal.nil? + ret = { msg: msg, palette: pal, error: err } + + if !ret[:palette].empty? + themes = Palette::NAMES.map(&:downcase) + + # If no perfect matches + if !themes.include?(ret[:palette].downcase) + # Look for partial matches + matches = themes.select{ |t| !!t[/#{pal}/i] } + if matches.size == 1 + ret[:palette] = matches.first + return ret + end + match_limit = 10 + perror("Multiple matches found for palette #{verbatim(ret[:palette])}#{matches.size > match_limit ? " (#{matches.size} matches, showing #{match_limit})" : ''}:\n#{format_block(matches.take(match_limit).join("\n"))}") if matches.size > 0 + + # Look for approximate matches + matches = string_distance_list_mixed(ret[:palette].downcase, themes.each_with_index.to_a.map(&:reverse)).map(&:last) + if matches.size == 1 + ret[:palette] = matches.first + return ret + end + perror("No matches found for palette #{verbatim(ret[:palette])}. Did you mean...\n#{format_block(matches.join("\n"))}") if matches.size > 0 + + # No matches whatsoever + perror("No good matches for palette #{verbatim(ret[:palette])} found.") if !fallback + err = "No good matches for palette #{verbatim(ret[:palette])} found, using default instead." + pal = '' + end + end + + # Fall back to default if no explicit palette + if pal.empty? + p = parse_user(event.user).palette rescue nil + pal = p || dflt + end + + err += "\n" if !err.empty? + { msg: msg, palette: pal, error: err } +rescue => e + lex(e, 'Failed to parse palette') +end + +def parse_global(msg) + DEFAULT_GLOBAL ? !msg[/\bnewest\b/i] : !!msg[/\bglobal\b/i] +end + +def parse_full(msg) + !!msg[/full/i] +end + +def parse_ties(msg, rtype = nil) + !rtype.nil? && rtype[-2..-1] == '_t' || !!msg[/\bties\b/i] +end + +def parse_tied(msg) + !!msg[/\btied\b/i] +end + +def parse_singular(msg) + if !!msg[/\bsingular\b/i] + 1 + elsif !!msg[/\bplural\b/i] + -1 + else + 0 + end +end + +def parse_gm(msg) + !!msg[/\bG--/i] || !!msg[/\bng\b/i] +end + +def parse_gp(msg) + !!msg[/\bG\+\+/i] || !!msg[/\bag\b/i] +end + +# 'strict' means the emoji must be separated from text +# intended to ignore users with it in the name +def parse_cool(msg, strict = false) + !!msg[/\bcool(s|(ness))?\b/i] || !!msg[/\bckc'?s?\b/i] || !!msg[/#{strict ? "(^|\s)" : ""}😎'?s?#{strict ? "($|\s)" : ""}/i] +end + +# see parse_cool for 'strict' +# if 'name' then we accept 'star' for parsing stars as well +def parse_star(msg, strict = false, name = false) + !!msg[/#{strict ? "(^|\s)" : ""}\*#{strict ? "($|\s)" : ""}/i] || name && !!msg[/\bstars?\b/i] +end + +# Parse type of leaderboard (highscore, speedrun, dual, ...) +# Second parameter determines the default +def parse_board(msg, dflt = nil, dual: false) + return dflt if msg.to_s.empty? + board = nil + board = 'dual' if !!msg[/\bdual\b/i] && dual + board = 'hs' if !!msg[/\bhs\b/i] || !!msg[/\bhigh\s*scores?\b/i] + board = 'gm' if !!msg[/\bng\b/i] || !!msg[/\bg--(\s|$)/i] + #board = 'gp' if !!msg[/\bagd?\b/i] || !!msg[/\bg\+\+(\s|$)/i] + board = 'sr' if !!msg[/\bsr\b/i] || !!msg[/\bspeed\s*runs?\b/i] + board = dflt if board.nil? + board +end + +# Parse arguments and flags CLI-style: +# Arg names must start with a dash and be alphanumeric (underscores allowed) +# Arg values can be anything but dashes +def parse_flags(input) + is_event = input.is_a?(Discordrb::Events::Event) + is_string = input.is_a?(String) + return {} if !is_event && !is_string + return parse_flags(remove_command(parse_message(input))) if is_event + input.scan(/(?:\s+|^)-(\w+)(?:\s+([^-].*?))?(?=\s+-|$)/) + .uniq{ |e| e[0] } + .map{ |k, v| [k, v.nil? ? nil : v.squish] } + .to_h + .symbolize_keys +rescue + {} +end + +# Determine whether an event is initial (the first time a post gets made) +# or not (an edit, like after a button press) +def parse_initial(event) + !event.is_a?(Discordrb::Events::ComponentEvent) +end + +# Determine if the event originated from a slash command +def is_slash?(event) + event.is_a?(Discordrb::Events::ApplicationCommandEvent) +end + +# Parse interpolated scores +def parse_frac(msg, mappack = nil, board = 'hs') + explicit = !!msg[/\bfrac(tion(al)?)?\b/i] || !!msg[/\binterpolated?\b/i] + implicit = mappack&.fractional + conditions = ['hs', 'sr'].include?(board) + (explicit || implicit) && conditions +end + +# Scores that are better than the dev's +def parse_dev(msg) + !!msg[/\bdev\b/] +end + +# Whether the response should be spoilered or not +def parse_spoiler(msg, h = nil, channel = nil) + old_enough = true + if h && !h.is_userlevel? + mappack = h.map.mappack + days = DAYS_PER_EPISODE * mappack.episodes.count + old_enough = mappack.date < days.days.ago + in_dms = channel.type == channel_type(:dm) rescue false + end + !!msg[/\bspoiler\b/i] || !old_enough && !in_dms +end + +# Use Time.parse to parse the date. This method is very flexible so it allows +# many different formats almost seamlessly. In fact, it's too flexible and infers +# missing values using a placeholder class that responds to day, mon and year. +# Sometimes this is too flexible, in those cases we can return nil instead. +def parse_date(str, infer: false) + validator = Class.new + def validator.day() raise end + def validator.mon() raise end + def validator.year() raise end + inferrer = infer ? Time.now : validator.new + Time.parse(str, inferrer) rescue nil +end + +def format_dev(dev) + dev ? 'over-DEV' : '' +end + +def format_rank(rank) + !rank ? '' : rank.to_i == 1 ? '0th' : "top #{rank}" +end + +# Formats a ranking type. These can include the range in some default cases +# (e.g. Top10 Rankings), unless the 'range' parameter is false. +# 'range' Includes the range (e.g. Top10) or not +# 'rank' Override whatever the rank in the rtype is +# 'basic' Doesn't print words which are now parameters (e.g. cool) +def format_rtype(rtype, range: true, rank: nil, basic: false) + if rtype[0..2] == 'top' + if range + rtype = format_rank(rank || rtype[/\d+/i] || 1) + else + rtype = rtype.split('_')[1..-1].join('_') + end + end + rtype = rtype.gsub('top1', '0th').tr('_', ' ') + rtype.remove!('cool', '*', 'maxed', 'maxable') if basic + "#{rtype.squish} ".squish +end + +def format_bottom_rank(rank) + "bottom #{20 - rank}" +end + +def format_board(board) + case board + when 'hs' + 'highscore' + when 'sr' + 'speedrun' + when 'gm' + 'G--' + when 'gp' + 'G++' + when 'dual' + 'dual' + else + 'highscore' + end +end + +def format_frac(frac) + frac ? 'interpolated' : '' +end + +# 'empty' means we print nothing +# This is used for when the calling function actually has other parameters +# that make is unnecessary to actually print the range +def format_range(bott, rank, empty = false) + return '' if empty || !bott && !rank + if bott && rank && bott == rank - 1 + "#{bott.ordinalize}" + elsif !bott || bott == 0 + format_rank(rank) + elsif !rank + "sub-#{bott.ordinalize}" + elsif rank == 20 + format_bottom_rank(bott) + else + "#{bott.ordinalize}-#{(rank - 1).ordinalize}" + end +end + +def format_singular(sing) + case sing + when 1 + 'singular' + when -1 + 'plural' + else + '' + end +end + +def format_cool(cool) + cool ? 'cool' : '' +end + +def format_star(star, long: false) + star ? (long ? 'star' : '*') : '' +end + +def format_maxed(maxed) + maxed ? 'maxed' : '' +end + +def format_maxable(maxable) + maxable ? 'maxable' : '' +end + +# Support for any single and multiple types +# 'empty' allows for no type, otherwise it's 'overall', +# which defaults to levels and episodes +def format_type(type, empty = false) + return 'Overall' if type.nil? + return type.to_s.remove('Mappack') if !type.is_a?(Array) + type.map!{ |t| t.to_s.remove('Mappack') } + case type.size + when 0 + empty ? '' : 'Overall' + when 1 + type.first + when 2 + if type.include?('Level') && type.include?('Episode') + 'Overall' + else + type.map{ |t| t.to_s.downcase }.join(" and ").capitalize + end + when 3 + 'Overall (w/ HC)' + end +end + +def format_mappack(mappack) + !mappack.nil? ? "for #{verbatim(mappack.name)} mappack" : '' +end + +def format_ties(ties) + ties ? '(w/ ties)' : '' +end + +def format_tied(tied) + tied ? 'tied' : '' +end + +def format_tab(tab) + tab == :ST ? '?! & !?' : (tab == :SS2 ? '!' : (tab == :SS ? '?' : tab.to_s)) +end + +def format_tabs(tabs) + tabs.map { |t| format_tab(t) }.to_sentence +end + +def format_time(long: true, prep: true) + str = long ? '%A %B %-d at %H:%M:%S (%z)' : '%b %d, %Y (%H:%M) UTC' + str.prepend('on ') if prep + Time.now.utc.strftime(str) +end + +def format_global(full) + full ? 'global' : 'newest' +end + +def format_full(full) + full ? 'full' : '' +end + +def format_max(n, min = false, bd: true) + return '' if !n + str = min ? 'MIN' : 'MAX' + num = (n.is_a?(Integer) ? "%d" : "%.3f") % n + str = "__#{str}__: **#{num}**" + str = "[#{str}]".tr('_*', '') if bd + str +end + +def format_author(author) + return '' if !author.is_a?(UserlevelAuthor) + "on maps by #{verbatim(author.name)}" +end + +# Format a timespan in seconds as a string of two possible forms: +# - Default: Xd Xh Xm Xs Xms. +# - ISO: XX:XX:XX.XXX +# Parameters: +# - time: Number of seconds (Integer/Float) +# - prec: How many terms to print +# - pad: Pad each term with zeroes to 2 or 3 digits +# - ms: Add a ms term at the end (only if time is Float) +# - iso: Toggles the ISO format, otherwise Default is used. +# - zero: Show 0 terms instead of skipping them. +# Notes: +# - ISO mode turns on padding and zeroes automatically. +def format_timespan(time, prec = -1, pad: false, ms: false, iso: false, zero: false) + return '' unless time.is_a?(Numeric) + + # Normalize params + ms &&= time.is_a?(Float) + zero ||= iso + pad ||= iso + + # Initialize vars + levels = [ + ['y', 365 * 86400], + ['d', 86400], + ['h', 3600], + ['m', 60], + ['s', 1] + ] + levels << (['ms', 0.001]) if ms + prec = levels.size if prec < 0 + terms = [] + + # Craft term list + levels.each{ |name, seconds| + break if prec <= 0 + init = !terms.empty? + next if time < seconds && (!zero || !init) + padding = pad && init ? (name == 'ms' ? 3 : 2) : 0 + terms << "%0*d%s" % [padding, (time / seconds).to_i, !iso ? name : ''] + time %= seconds + prec -= 1 + } + terms << '-' if terms.empty? + + # Concatenate and build final string + str = terms.join(iso ? ':' : ' ') + str.sub!(/.*\K:/, '.') if iso && ms + str +end + +# Format a size in bytes +def format_size(size, prec = 2, bin: true) + b = bin ? 1024 : 1000 + i = Math::log(size, b).to_i + return "#{size} B" if i == 0 + '%.*f %s%sB' % [prec, size.to_f / b ** i, 'BKMGTPEZY'[i], bin ? 'i' : ''] +end + +# Obtain a custom Discord timestamp. Format: +# or +# Supported styles: +# t 16:20 Short Time +# T 16:20:30 Long Time +# d 20/04/2021 Short Date +# D 20 April 2021 Long Date +# f 20 April 2021 16:20 Short DateTime +# F Tuesday, 20 April 2021 16:20 Long DateTime +# R 2 months ago Rel. Time +# Default: f +def format_timestamp(t, time: false, date: false, full: false, rel: false, long: false) + fmt = time ? 'T' : date ? 'D' : full ? 'F' : rel ? 'R' : '' + fmt.downcase! if !long && !rel + fmt.prepend(':') unless fmt.empty? + "" +end + +def format_date(date) + date ? 'on ' + format_timestamp(date, date: true, long: true) : '' +end + +def format_cheaters(cheaters) + cheaters ? ' [with cheaters]' : '' +end + +def format_sentence(e) + return e[0].to_s if e.size == 1 + e[-2] = e[-2].to_s + " and #{e[-1].to_s}" + e[0..-2].map(&:to_s).join(", ") +end + +def format_level_list(levels) + return if levels.empty? + pad = levels.map{ |l| l.name.length }.max + 1 + format_block(levels.map{ |s| s.name.ljust(pad, ' ') + s.longname }.join("\n")) +end + +# Format a list of levels resulting from a search +def format_level_matches(event, msg, page, matches, name) + # Truncate list + list = matches[1] + if list.size > PAGE_SIZE + components = event.respond_to?(:message) ? event.message.components : nil + page = parse_page(msg, page, false, components) + pag = compute_pages(list.size, page) + list = list[pag[:offset]...pag[:offset] + PAGE_SIZE] + end + + # Print list and optionally add navigation buttons and screenshot + content = "#{name.capitalize}: #{matches[0]}\n#{format_level_list(list)}" + view = matches[1].size > PAGE_SIZE ? interaction_add_button_navigation(nil, pag[:page], pag[:pages]) : nil + if IMAGESEARCH_AUTO && matches[1].size <= IMAGESEARCH_LIMIT + hash = parse_palette(event) + screenshot = Map.screenshot(hash[:palette], file: true, h: list) + attachments = [screenshot] if screenshot + end + send_message(event, content: content, components: view, files: attachments || []) + perror('', log: false, discord: false) +end + +# Header of outte messages +def format_header(header, close: ':', upcase: true, rich: false) + header.squish! + header[0] = header[0].upcase if upcase + header += close if close +end + +def tmp_filename(name, embed: false) + new_name = embed ? sanitize_filename_embed(name) : sanitize_filename(name) + File.join(Dir.tmpdir, new_name) +end + +def tmp_file(data, name = 'result.txt', binary: false, file: true, embed: false) + tmpfile = tmp_filename(name, embed: embed) + File::open(tmpfile, binary ? 'wb' : 'w', crlf_newline: !binary) do |f| + f.write(data) + end + file ? File::open(tmpfile) : tmpfile +end + +def send_file(event, data, name = 'result.txt', binary = false, spoiler = false) + return nil if data.nil? + name.prepend('SPOILER_') if spoiler + event.attach_file(tmp_file(data, name, binary: binary)) +end + +def file_url(file) + "attachment://#{File.basename(file.path)}" +end + +# TODO: Adapt to V2-component-based messages +# Log the main aspects of a message to the terminal right after sending it to Discord +def log_message(content, files = [], components = nil, embeds = [], edit: false) + # Function to format numbers + fmt = -> (n) { + f = n.is_a?(Float) ? '.3f' : 'd' + "%s%#{f}%s" % [ANSI.esc([ANSI::BOLD, ANSI::BRIGHT_CYAN]), n, ANSI.esc([ANSI::NOWEIGHT, ANSI::CYAN])] + } + + # Log message content length + properties = [] + properties << "%s chars" % [fmt.(content.length)] unless content.empty? + + # Log message attachments and size + if !files.empty? + size = files.map{ |f| File.size(f.path) rescue 0 }.sum / 1024.0 + properties << "%s attachments (%s KiB)" % [fmt.(files.length), fmt.(size)] + end + + # Log message components and type + if false #components + comp_list = components.to_a.map{ |row| row[:components] }.flatten + comp_count = comp_list.length + row_count = components.rows.size + types = Discordrb::Webhooks::View::COMPONENT_TYPES.invert + button_count = comp_list.count{ |c| types[c[:type]] == :button } + menu_count = comp_list.count{ |c| types[c[:type]].to_s =~ /_select/ } + args = [comp_count, row_count, button_count, menu_count].map{ |a| fmt.(a) } + properties << "%s components (%s rows, %s buttons, %s menus)" % args unless comp_count == 0 + end + + # Log message embeds properties + if !embeds.empty? + embed_length = embeds.map{ |e| e.description.to_s.length }.sum + embed_size = embeds.map{ |e| e.fields.to_a.size }.sum + args = [embeds.count, embed_length, embed_size].map{ |a| fmt.(a) } + properties << "%s embeds (%s chars, %s fields)" % args + end + + # Log message characteristics + args = [ANSI.esc([ANSI::UNDER]), edit ? 'Edited' : 'Sent', ANSI.esc([ANSI::NOUNDER]), properties.join(', ')] + lout("%s%s message%s: %s" % args) + + # Log the start of the message + summary = !content.empty? ? content : !embeds.empty? ? embeds.first.title : nil + if summary + summary = ANSI.unesc(summary) + args = [ANSI.esc([ANSI::FAINT]), summary.squish[0, 80], summary.length > 80 ? '...' : '', ANSI.esc([ANSI::NOWEIGHT])] + lout("%s\"%s%s\"%s" % args) + end +rescue => e + lex(e, 'Failed to log Discord message to the terminal') +end + +# Send a message to a destination (typically a respondable event or a Discord channel) +# If the parameters are empty, then the content/file already appended to the event will +# be used, if any. Register msg in db at the end. +def send_message( + dest, # Destination (respondable event, or channel) + content: '', # Message text, unless it's v2 (see below) + files: [], # Message attachments + embeds: [], # Rich embed + components: nil, # Message components (buttons, select menus...) + spoiler: false, # Whether to spoiler the attachments + db: true, # Whether we should register this msg in the db + edit: true, # Whether a component event should edit or send a new msg + append: false, # Whether to append content to preexisting message + log: true, # Whether to log the sent message in the terminal + ephemeral: false, # Only the user can see the message, available for interaction responses + v2: false # Send V2-based component message (IS_COMPONENTS_V2) + ) + # Save stuff already appended to message, and remove it to prevent autosend + if dest.is_a?(Discordrb::Events::Respondable) # Grab message + content = dest.saved_message.dup if content.empty? rescue '' + dest.drain + end + + if dest.is_a?(Discordrb::Events::MessageEvent) # Grab attachment + files << dest.file if files.empty? + spoiler ||= !!dest.file_spoiler + dest.detach_file + end + + # Config and return if no message + content.strip! + embeds.compact! + content = '' if v2 + embeds = [] if v2 + files.reject!{ |f| !f.is_a?(File) } + return if content.empty? && files.empty? && embeds.empty? && components.nil? + + # Include dynamically created components + components = $components if !components && $components && !$components.to_a.empty? + + # Only update message if it's a component event (no need to log) + if edit && dest.is_a?(Discordrb::Events::ComponentEvent) + content = dest.message.content + "\n" + content if append + # The v2 flag cannot be removed once set + if dest.message.flags[15] == 1 + components = view_add_text(content: content) if !components + v2 = true + content = '' + embeds = [] + end + action_inc('edits') + log_message(content, files, components, embeds, edit: true) if log + return dest.update_message(content: content, embeds: embeds, components: components, has_components: v2, ephemeral: ephemeral) + end + + # Manually spoiler attachments if necessary + files.map!{ |f| + next f if File.basename(f).start_with?('SPOILER_') + new_name = File.join(File.dirname(f), 'SPOILER_' + File.basename(f)) + File.rename(f.path, new_name) + f.close + File.open(new_name, 'r') + } if spoiler + + # Send message (syntax varies depending on event type) + case dest + when Discordrb::Channel, Discordrb::Events::Respondable # Channel, messages, reactions + msg = dest.send_message!(content: content, embeds: embeds, components: components, attachments: files, has_components: v2) + when Discordrb::Events::InteractionCreateEvent # Components, application commands + msg = dest.respond(content: content, embeds: embeds, components: components, attachments: files, has_components: v2, wait: true, ephemeral: ephemeral) + end + return if !msg + + # Log msg and return + user_id = dest.user.id if dest.respond_to?(:user) + Message.create(id: msg.id, user_id: user_id, date: Time.now) if user_id && db + action_inc('messages') + log_message(content, files, components, embeds) if log + msg +rescue => e + lex(e, 'Failed to send message to Discord') + nil +end + +# Done loading file +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) diff --git a/src/mappacks.rb b/src/mappacks.rb new file mode 100644 index 00000000..6416267c --- /dev/null +++ b/src/mappacks.rb @@ -0,0 +1,1969 @@ +# This file includes all the classes related to mappacks (levels, episodes, stories, +# demos, etc). It parses new mappacks, calculates integrity hashes, etc. Notably, +# it handles all CLE server communications (score submission, leaderboards, replay +# downloading, etc). + +$load_time = Time.now + +class Mappack < ActiveRecord::Base + has_many :palettes + has_many :mappack_scores + has_many :mappack_levels + has_many :mappack_episodes + has_many :mappack_stories + has_many :mappack_channels + alias_method :scores, :mappack_scores + alias_method :levels, :mappack_levels + alias_method :episodes, :mappack_episodes + alias_method :stories, :mappack_stories + alias_method :channels, :mappack_channels + + # Parse all mappacks in the mappack directory into the database + # update - Update preexisting mappacks (otherwise, only parses newly added ones) + # all - Update all versions of each mappack + # hard - Perform a hard update (see self.read) + def self.seed(update: false, all: true, hard: false) + # Fetch mappacks + perror("Mappacks directory not found, not seeding.", log: true) if !Dir.exist?(DIR_MAPPACKS) + mappacks = {} + Dir.entries(DIR_MAPPACKS).select{ |d| !!d[/\d+_.+/] }.sort.map{ |d| + id, code, version = d.split('_') + mappacks[code] = { id: id.to_i, versions: [] } if !mappacks.key?(code) + mappacks[code][:id] = id.to_i + mappacks[code][:versions] << version.to_i + } + + # Integrity checks: + # - Ensure no ID conflicts + # - Ensure all versions are present + mappacks.group_by{ |code, atts| atts[:id] }.each{ |id, packs| + perror("Mappack ID conflict: #{packs.map{ |p| p[0].upcase }.join(', ')}.", log: true) if packs.size > 1 + } + mappacks.each{ |code, atts| + missing = (1 .. atts[:versions].max).to_a - atts[:versions] + perror("#{code.upcase} missing versions #{missing.join(', ')}.", log: true) unless missing.empty? + } + + # Read mappacks + mappacks.each{ |code, atts| + id = atts[:id] + versions = atts[:versions].sort + + versions.each{ |version| + next if !all && version < versions.max + mappack = Mappack.find_by(id: id) + if mappack + next unless update + perror("Mappack with ID #{id} already belongs to #{mappack.code.upcase}.", log: true) if mappack.code.upcase != code.upcase + if version < mappack.version && !hard + perror("Cannot soft update #{code.upcase} to v#{version} (already at v#{mappack.version}).", log: true) + end + mappack.update(version: version) + mappack.read(v: version, hard: hard) + else + perror("#{code.upcase} v1 should exist (trying to create v#{version}).", log: true) if version != 1 + Mappack.create(id: id, code: code, version: 1).read(v: 1, hard: true) + end + } + } + + # Update mappack digest + digest + rescue => e + lex(e, "Error seeding mappacks to database") + end + + # Update the digest file, which summarizes mappack info into a file that can + # be queried via the internet. Only write to disk when core information has + # changed (e.g. new mappack) using a signature. Returns whether the digest + # has been updated or not. + def self.digest + # Get current digest signature to detect changes + if File.file?(PATH_MAPPACK_INFO) + old_signature = JSON.parse(File.read(PATH_MAPPACK_INFO))['signature']['md5'].downcase rescue nil + end + + # Fetch score counts per mappack. For mappacks with forwarded scores, we also + # must tally the corresponding userlevel completion counts. + score_counts = MappackScore.where('rank_hs IS NOT NULL OR rank_sr IS NOT NULL') + .group(:mappack_id) + .pluck(:mappack_id, 'count(mappack_id)') + .to_h + + forwarded_counts = MappackLevel.joins('INNER JOIN userlevels ON mappack_levels.userlevel_id = userlevels.id') + .where(forward: true) + .group(:mappack_id) + .pluck(:mappack_id, 'sum(userlevels.completions)') + .to_h + + # Fetch unique player counts per mappack, does not include those that only played + # forwarded userlevels without installing the pack + player_counts = MappackScore.group(:mappack_id) + .pluck(:mappack_id, 'count(distinct player_id)') + .to_h + + # Fetch counts for the last month too, to measure recent activity + recent = Time.now - 1.month + recent_score_counts = MappackScore.where('(rank_hs IS NOT NULL OR rank_sr IS NOT NULL) AND date >= (?)', recent) + .group(:mappack_id) + .pluck(:mappack_id, 'count(mappack_id)') + .to_h + recent_player_counts = MappackScore.where('date >= (?)', recent) + .group(:mappack_id) + .pluck(:mappack_id, 'count(distinct player_id)') + .to_h + last_active = MappackScore.group(:mappack_id).pluck(:mappack_id, 'max(date)').to_h + + # Pack a bunch of useful info into a hash and JSONify it, we use the last version of each mappack + dig = { + config: { + levels_dir: DIR_MAPPACK_LEVELS, + palettes_dir: DIR_MAPPACK_PALETTES, + level_files: TABS_NEW.map{ |k, v| v[:files].keys }.flatten.map{ |f| f + '.txt' }, + challenge_files: TABS_NEW.select{ |k, v| v[:challenges] }.map{ |k, v| v[:files].keys }.flatten.map{ |f| f + 'codes.txt' }, + authors_file: FILENAME_MAPPACK_AUTHORS, + scores_file: FILENAME_MAPPACK_SCORES + }, + tabs: Mappack.where(public: true).order(:id).map{ |mappack| + next unless mappack.source + hash = {} + hash[:attributes] = JSON.parse(mappack.to_json) + hash[:download] = { + link: github_link(mappack.source), + size: mappack.get_size(true), + md5: Digest::MD5.file(mappack.source).hexdigest + } + hash[:disk] = { + size: mappack.get_size(false), + level_files: mappack.get_files(:levels).map{ |f| File.basename(f) }, + challenge_files: mappack.get_files(:challenges).map{ |f| File.basename(f) }, + palettes: mappack.get_files(:palettes).map{ |f| File.basename(f) }, + authors_file: !!mappack.get_files(:authors), + scores_file: !!mappack.get_files(:scores) + } + hash[:properties] = { + modes: mappack.levels.pluck('distinct mode').map{ |m| MODES[m] }, + tabs: mappack.levels.pluck('distinct tab').map{ |tab| TABS_NEW[tab.to_sym][:code] }, + levels: mappack.levels.count, + episodes: mappack.episodes.count, + stories: mappack.stories.count + } + hash + }.compact + } + + # Update digest on disk if signature changed. We add stats now, because they're chaging all the + # time and we'd end up commiting new digests constantly. + new_signature = md5(dig.to_json, hex: true).downcase + return false if new_signature == old_signature + dig[:tabs].each{ |hash| + id = hash[:attributes]['id'] + hash[:stats] = { + scores: score_counts[id].to_i + forwarded_counts[id].to_i, + recent_scores: recent_score_counts[id].to_i, + last_active: last_active[id]&.utc&.iso8601, + players: player_counts[id].to_i, + recent_players: recent_player_counts[id].to_i + } + } + dig[:signature] = { + md5: new_signature, + date: Time.now.utc.iso8601 + } + File.write(PATH_MAPPACK_INFO, JSON.pretty_generate(dig)) + log("Updated mappack digest: #{new_signature}") + true + rescue => e + lex(e, 'Failed to generate mappack digest file') + end + + # Return the file or folder that contains this mappack's files + def source(v: nil) + src = File.join(DIR_MAPPACKS, '%03d_%s_%d.zip' % [id, code, v || version || 1]) + File.file?(src) ? src : nil + end + + # Retrieve the size of the mappack on disk + def get_size(download = true, v: nil) + return unless src = source(v: v) + return File.size(src) if download + zip = Zip::File.open(src) + zip.each_entry.sum(&:size) + ensure + zip&.close + end + + # Mappack file list getter. That way we can support changing the directory tree in the future. + def get_files(type, v: nil) + return unless src = source(v: v) + zip = Zip::File.open(src) + files = zip.entries.select{ |e| e.ftype == :file }.map(&:name) + folders = zip.entries.select{ |e| e.ftype == :directory }.map(&:name) + case type + when :levels + valid = TABS_NEW.map{ |k, v| v[:files].keys }.flatten.map{ |f| f + '.txt' } + files.select{ |f| + File.dirname(f) == DIR_MAPPACK_LEVELS && valid.include?(File.basename(f)) + }.sort + when :challenges + valid = TABS_NEW.select{ |k, v| v[:challenges] }.map{ |k, v| v[:files].keys }.flatten.map{ |f| f + 'codes.txt' } + files.select{ |f| + File.dirname(f) == DIR_MAPPACK_LEVELS && valid.include?(File.basename(f)) + }.sort + when :authors + files.include?(FILENAME_MAPPACK_AUTHORS) ? FILENAME_MAPPACK_AUTHORS : nil + when :scores + files.include?(FILENAME_MAPPACK_SCORES) ? FILENAME_MAPPACK_SCORES : nil + when :palettes + folders.select{ |f| + File.dirname(f) == DIR_MAPPACK_PALETTES + }.sort + end + ensure + zip&.close + end + + # Mappack file getter + def get_file(path, v: nil) + return unless src = source(v: v) + zip = Zip::File.open(src) + return unless entry = zip.find_entry(path) + entry.get_input_stream.read + ensure + zip&.close + end + + # TODO: Parse challenge files, in a separate function with its own command, + # which is also called from the general seed and read functions. + + # Parses map files corresponding to this mappack, and updates the database + # v - Specifies the version of the mappack + # hard - A hard update is aimed at versions with significant changes, + # e.g., different amount of maps. In this case, the highscoreables + # are deleted. For soft updates, checks of similarity are enforced, + # and a report of changes is printed. + # discord - Log errors back to Discord. + def read(v: nil, hard: false) + # Integrity check for mappack version + v = version || 1 if !v + perror("Cannot soft update an older mappack version (#{v} vs #{version}).", log: true) if v < version && !hard + name_str = "#{code.upcase} v#{v}" + + # Check for mappack directory + log("Parsing mappack #{name_str}...") + paths = get_files(:levels) + alert("No appropriate files found in directory for mappack #{name_str}") if paths.empty? + + if !hard + # Soft updates: Ensure the new tabs will replace the old ones precisely + tabs_old = MappackLevel.where(mappack_id: id).distinct.pluck('`tab` AS `tab_int`').sort + tabs_new = paths.map{ |path| + tab = TABS_NEW.values.find{ |att| att[:files].key?(File.basename(path)[0..-5]) } + tab ? tab[:mode] * 7 + tab[:tab] : nil + }.compact.uniq.sort + perror("Tabs for mappack #{code.upcase} do not coincide, cannot do soft update.", log: true) if tabs_old != tabs_new + else + # Hard updates: Delete highscoreables + levels.delete_all(:delete_all) + episodes.delete_all(:delete_all) + stories.delete_all(:delete_all) + end + + # Delete map data from newer versions + MappackData.joins('INNER JOIN `mappack_levels` ON `mappack_levels`.`id` = `highscoreable_id`') + .where("`mappack_id` = #{id} AND `version` >= #{v}").delete_all + + # Parse mappack files + file_errors = 0 + map_errors = 0 + changes = { name: 0, tiles: 0, objects: 0 } if !hard + paths.each{ |path| + # Find corresponding tab + f = File.basename(path) + tab_code = f[0..-5] + tab = TABS_NEW.values.find{ |att| att[:files].key?(tab_code) } + if tab.nil? + alert("Unrecognized file #{tab_code} parsing mappack #{name_str}") + next + end + + # Parse file + maps = Map.parse_metanet_file(path, tab[:files][tab_code], name_str, data: get_file(path)) + if maps.nil? + file_errors += 1 + perror("Parsing of #{name_str} #{f} failed, ending soft update.", log: true) if !hard + next + end + + # Precompute some indices for the database + mappack_offset = TYPES['Level'][:slots] * id + file_index = tab[:files].keys.index(tab_code) + file_offset = tab[:files].values.take(file_index).sum + tab_offset = tab[:start] + tab_index = tab[:mode] * 7 + tab[:tab] + + count = maps.count + # In soft updates, map count must be the same (or smaller, if tab is + # partitioned in multiple files, but never higher) + perror("Map count in #{code.upcase} #{f} exceeds database ones, must do hard update.", log: true) if !hard && count > levels.where(tab: tab_index).count + + # Create new database records + maps.each_with_index{ |map, map_offset| + dbg("#{hard ? 'Creating' : 'Updating'} record #{"%-3d" % (map_offset + 1)} / #{count} from #{f} for mappack #{name_str}...", newline: false) + if map.nil? + map_errors += 1 + perror("Parsing of #{name_str} #{f} map #{map_offset} failed, ending soft update.", log: true) if !hard + next + end + tab_id = file_offset + map_offset # ID of level within tab + inner_id = tab_offset + tab_id # ID of level within mappack + level_id = mappack_offset + inner_id # ID of level in database + + # Create mappack level + change_level = false + if hard + level = MappackLevel.find_or_create_by(id: level_id) + change_level = true + else + level = MappackLevel.find_by(id: level_id) + perror("#{code.upcase} level with ID #{level_id} should exist.", log: true) if !level + if map[:title].strip != level.longname + changes[:name] += 1 + change_level = true + end + end + + level.update( + inner_id: inner_id, + mappack_id: id, + mode: tab[:mode], + tab: tab_index, + episode_id: level_id / 5, + name: code.upcase + '-' + compute_name(inner_id, 0), + longname: map[:title].strip, + gold: map[:gold] + ) if change_level + + # Save new mappack data (tiles and objects) if: + # Hard update - Always + # Soft update - Only when the map data is different + prev_tiles = level.tile_data(version: v - 1) + new_tiles = Map.encode_tiles(map[:tiles]) + save_tiles = prev_tiles != new_tiles + + prev_objects = level.object_data(version: v - 1) + new_objects = Map.encode_objects(map[:objects]) + save_objects = prev_objects != new_objects + + new_data = hard || save_tiles || save_objects + if new_data + data = MappackData.find_or_create_by(highscoreable_id: level_id, version: v) + if hard || save_tiles + data.update(tile_data: new_tiles) + changes[:tiles] += 1 if !hard + end + if hard || save_objects + data.update(object_data: new_objects) + changes[:objects] += 1 if !hard + end + end + + # Create corresponding mappack episode, except for secret tabs. + next if tab[:secret] || level_id % 5 > 0 + story = tab[:mode] == 0 && (!tab[:x] || map_offset < 5 * tab[:files][tab_code] / 6) + + episode = MappackEpisode.find_by(id: level_id / 5) + if hard + episode = MappackEpisode.create( + id: level_id / 5, + inner_id: inner_id / 5, + mappack_id: id, + mode: tab[:mode], + tab: tab_index, + story_id: story ? level_id / 25 : nil, + name: code.upcase + '-' + compute_name(inner_id / 5, 1) + ) unless episode + else + perror("#{code.upcase} episode with ID #{level_id / 5} should exist, stopping soft update.", log: true) if !episode + end + + # Create corresponding mappack story, only for non-X-Row Solo. + next if !story || level_id % 25 > 0 + + story = MappackStory.find_by(id: level_id / 25) + if hard + story = MappackStory.create( + id: level_id / 25, + inner_id: inner_id / 25, + mappack_id: id, + mode: tab[:mode], + tab: tab_index, + name: code.upcase + '-' + compute_name(inner_id / 25, 2) + ) unless story + else + perror("#{code.upcase} story with ID #{level_id / 25} should exist, stopping soft update.", log: true) if !story + end + } + Log.clear + + # Log results for this file + count = maps.count(nil) + map_errors += count + if count == 0 + dbg("Parsed file #{tab_code} for mappack #{name_str} without errors", pad: true) + else + alert("Parsed file #{tab_code} for mappack #{name_str} with #{count} errors", pad: true) + end + } + + # Fill in episode and story gold counts based on their corresponding levels + episode_count = episodes.size + episodes.find_each.with_index{ |e, i| + dbg("Setting gold count for #{name_str} episode #{i + 1} / #{episode_count}...", progress: true) + e.update(gold: MappackLevel.where(episode_id: e.id).sum(:gold)) + } + Log.clear + story_count = stories.size + stories.find_each.with_index{ |s, i| + dbg("Setting gold count for #{name_str} story #{i + 1} / #{story_count}...", progress: true) + s.update(gold: MappackEpisode.where(story_id: s.id).sum(:gold)) + } + Log.clear + + # Update precomputed SHA1 hashes + MappackLevel.update_hashes(mappack: self) + MappackEpisode.update_hashes(mappack: self, pre: true) + MappackStory.update_hashes(mappack: self, pre: true) + + # Parse extra files (challenges, author names, dev scores, palettes) + read_challenges(v: v) + read_authors(v: v) + read_scores(v: v) + + # Log final results for entire mappack + if file_errors + map_errors == 0 + succ("Successfully parsed mappack #{name_str}") + self.update(version: v) + else + alert("Parsed mappack #{name_str} with #{file_errors} file errors and #{map_errors} map errors") + end + dbg("Soft update: #{changes[:name]} name changes, #{changes[:tiles]} tile changes, #{changes[:objects]} object changes.") if !hard + rescue => e + lex(e, "Error reading mappack #{name_str}") + end + + # Read the author list and write to the db + # Manual means the function was called from a special command rather than automatically when seeding the mappack + def read_authors(v: nil, manual: false) + # Integrity checks + path = get_files(:authors) + if !path + err("Authors file for mappack #{verbatim(code)} not found") if manual + return + end + + # Parse authors file + file = get_file(path) + names = file.split("\n").map(&:strip) + maps = levels.order(:id) + if maps.size != names.size + err("Authors file for mappack #{verbatim(code)} has incorrect length (#{names.size} names vs #{maps.size} maps)") + return + end + + # Write names + count = maps.size + maps.each_with_index{ |m, i| + dbg("Adding author #{i + 1} / #{count}...", pad: true, newline: false) + m.update(author: names[i]) + } + Log.clear + rescue => e + lex(e, "Failed to read authors file for mappack #{verbatim(code)}") + end + + # Read the score list and write to the db (about 'manual', see #read_authors) + def read_scores(v: nil, manual: false) + # Integrity checks + path = get_files(:scores) + if !path + err("Scores file for mappack #{verbatim(code)} not found") if manual + return + end + + # Parse scores file + file = get_file(path) + scores = file.split("\n").map{ |l| round_score(l.strip.to_f) } + maps = levels.order(:id) + if maps.size != scores.size + err("Scores file for mappack #{verbatim(code)} has incorrect length (#{scores.size} scores vs #{maps.size} maps)") + return + end + + # Write level scores (framecount assumes no gold) + count = maps.size + maps.each_with_index{ |m, i| + dbg("Adding level score #{i + 1} / #{count}...", pad: true, newline: false) + framecount = 90 * 60 - (scores[i] * 60.0).round + 1 + m.update(dev_hs: scores[i], dev_sr: framecount) + } + + # Write episode scores + count = episodes.size + episodes.each_with_index{ |m, i| + dbg("Adding episode score #{i + 1} / #{count}...", pad: true, newline: false) + framecount = m.levels.sum(:dev_sr) + score = [round_score(90 - (framecount - 5) / 60.0), 0].max + m.update(dev_hs: score, dev_sr: framecount) + } + + # Write story scores + count = stories.size + stories.each_with_index{ |m, i| + dbg("Adding story score #{i + 1} / #{count}...", pad: true, newline: false) + framecount = m.levels.sum(:dev_sr) + score = [round_score(90 - (framecount - 25) / 60.0), 0].max + m.update(dev_hs: score, dev_sr: framecount) + } + + Log.clear + rescue => e + lex(e, "Failed to read scores file for mappack #{verbatim(code)}") + end + + # Read the challengelist and write to the db (about 'manual', see #read_authors) + def read_challenges(v: nil, manual: false) + # Integrity checks + paths = get_files(:challenges) + if paths.empty? + err("Mappack #{code} has no challenge files") if manual + return + end + + # Logging variables + challenge_count_total = 0 + level_count_total = 0 + file_count_total = 0 + tab_count_total = 0 + + # Parse challenges + TABS_NEW.each{ |_, tab| + # Find tabs with all challenge files + files = tab[:files].map{ |name, count| [name + 'codes.txt', count] } + next unless files.all?{ |basename, count| paths.one?{ |path| File.basename(path) == basename } } + + # Logging variables + challenge_count = 0 + level_count = 0 + file_count = 0 + + # Parse each challenge file + level_id = TYPES['Level'][:slots] * id + tab[:start] + files.each{ |basename, count| + # Ensure count is correct + maps = get_file(paths.find{ |path| File.basename(path) == basename }).split("\n") + if maps.size != count + err("File #{basename} for tab #{tab[:code]} has #{maps.size} maps instead of the expected #{count}") + level_id += count + next + end + + # Parse each map's challenges + maps.each_with_index{ |map, i| + dbg("Parsing map #{level_count + 1} / #{tab[:size]} from #{tab[:code]}...", progress: true) + map.squish.split(" ").each_with_index{ |c, j| + objs = { "G" => 0, "T" => 0, "O" => 0, "C" => 0, "E" => 0 } + c.scan(/../).each{ |o| + objs[o[1]] = o[0] == "A" ? 1 : o[0] == "N" ? -1 : 2 + } + MappackChallenge.find_or_create_by(level_id: level_id, index: j) + .update(g: objs["G"], t: objs["T"], o: objs["O"], c: objs["C"], e: objs["E"]) + challenge_count += 1 + } + level_count += 1 + level_id += 1 + } + file_count += 1 + } + + # Log + dbg("Parsed #{challenge_count} challenges from #{level_count} levels in #{file_count} files for tab #{tab[:code]}") + challenge_count_total += challenge_count + level_count_total += level_count + file_count_total += file_count + tab_count_total += 1 + } + + # Log + succ( + "Parsed #{challenge_count_total} challenges from #{level_count_total} levels "\ + "in #{file_count_total} files from #{tab_count_total} tabs for mappack #{code}" + ) + end + + # Creates a palette object belonging to this mappack. Colors are packed as a RGBA binary string. + def add_palette(name, colors) + Palette.find_or_create_by(name: name, author: authors) + .update(mappack: self, date: date, colors: colors) + end + + # Check additional requirements for scores submitted to this mappack + # For instance, w's Duality coop pack requires that the replays for both + # players be identical + def check_requirements(demos) + case self.code + when 'dua' + demos.each{ |d| + # Demo must have even length (coop) + sz = d.size + if sz % 2 == 1 + alert("Demo does not satisfy Duality's requirements (odd length)") + return false + end + + # Both halves of the demo must be identical + if d[0...sz / 2] != d[sz / 2..-1] + alert("Demo does not satisfy Duality's requirements (different inputs)") + return false + end + } + true + else + true + end + rescue => e + lex(e, "Failed to check requirements for demo in '#{code}' mappack") + false + end + + # For mappacks containing already-published levels, find them and store the + # reference. For those that were truly unchanged, we'll forward scores to + # Metanet instead of storing them locally. + # If 'exact' is true, we enforce that levels have the same hash, otherwise + # we simply check that they have the same tiles and objects, which should suffice + # because the servers don't check hashes for userlevels. + def seed_userlevels(mode: nil, start: nil, stop: nil, exact: false, min_id: nil, max_id: nil) + # Filter userlevel range to search + userlevels = Userlevel.all + userlevels = userlevels.where(mode: mode) if mode + userlevels = userlevels.where("`date` >= '#{start}'") if start + userlevels = userlevels.where("`date` < '#{stop}'") if stop + + # Find matching userlevels. Sort by match quality, and then by date. + list = levels.where(userlevel: nil) + .where(min_id ? "inner_id >= #{min_id}" : '') + .where(max_id ? "inner_id <= #{max_id}" : '') + list.each_with_index{ |l, i| + dbg("Finding match for level #{i + 1} / #{list.length}...", progress: true) + matches = userlevels.where(title: l.longname) + .map{ |u| [u, l.distance(u)] } + .sort_by{ |u, d| [d, u.id] } + next if matches.size == 0 + u, d = matches.first + forward = exact ? l._hash(c: true) == u._hash(c: true) : d == 0 + l.update(userlevel: u, forward: forward) + } + end + + # Report maps which couldn't be mapped to their corresponding userlevel + def nonmatching_userlevels(start: nil, stop: nil) + min_id = Userlevel.where("`date` >= '#{start}'").minimum(:id) if start + max_id = Userlevel.where("`date` < '#{stop}'").maximum(:id) if stop + levels.where.not(userlevel: nil).where(forward: false) + .where(min_id ? "userlevel_id >= #{min_id}" : '') + .where(max_id ? "userlevel_id <= #{max_id}" : '') + end + + # Set some of the mappack's info on command, which isn't parsed from the files + def set_info(name: nil, author: nil, date: nil, channel: nil, version: nil, enabled: false, public: false, fractional: false) + self.update(name: name) if name + self.update(authors: author) if author + self.update(version: version) if version + self.update(enabled: enabled) if !enabled.nil? + self.update(public: public) if !public.nil? + self.update(fractional: fractional) if !fractional.nil? + self.update(date: Time.strptime(date, '%Y/%m/%d').strftime(DATE_FORMAT_MYSQL)) if date + channel.each{ |c| + if is_num(c) + ch = find_channel(id: c.strip.to_i) + else + ch = find_channel(name: c.strip) + end + perror("No channel found by the name #{verbatim(c.strip)}.") if !ch + chn = MappackChannel.find_or_create_by(id: ch.id) + channels << chn + chn.update(name: ch.name) + } if channel + rescue => e + lex(e, "Failed to set mappack '#{code}' info") + nil + end +end + +class MappackData < ActiveRecord::Base + belongs_to :mappack_level, foreign_key: :highscoreable_id + alias_method :level, :mappack_level + alias_method :level=, :mappack_level= +end + +module MappackHighscoreable + include Highscoreable + + def type + self.class.to_s + end + + def basetype + type.remove('Mappack') + end + + def version + versions.max + end + + # Stored SHA1 hash computed using STB's C implementation + def saved_hash(v: nil) + hashes.where(v ? "`version` <= #{v}" : '').order(:version).last&.sha1_hash + end + + + # Recompute SHA1 hash for all available versions + # If 'pre', then episodes/stories will not recompute their level hashes + def update_hashes(pre: false) + hashes.clear + versions.each{ |v| + hashes.create(version: v, sha1_hash: _hash(c: true, v: v, pre: pre)) + } + hashes.count + end + + # Return leaderboards, filtering obsolete scores and sorting appropiately + # depending on the mode (hs / sr). Only scores with non-null rank field + # are returned. Gold boards work differently. + def leaderboard( + m = 'hs', # Playing mode (hs, sr, gm) + truncate: 20, # How many scores to take (0 = all) + pluck: true, # Pluck or keep Rails relation + aliases: false, # Use player names or display names + metanet_id: nil, # Player making the request if coming from CLE + offset: 0, # Starting rank of the board + frac: false, # Include fractional field + obsolete: false, # Include obsolete runs (null rank) + date: nil, # Maximum date threshold (when no obsoletes, only works well when plucking) + cheated: false, # Include cheated runs, only for compatibility with Metanet ones + cools: false, # Compute cool scores + stars: false # Compute star scores + ) + m = 'hs' if !['hs', 'sr', 'gm'].include?(m) + names = aliases ? 'IF(display_name IS NOT NULL, display_name, name)' : 'name' + attr_names = %W[id score_#{m} name metanet_id player_id] + + # Check if a manual replay ID has been set, so that we only select that one + manual = GlobalProperty.find_by(key: 'replay_id').value rescue nil + use_manual = manual && metanet_id == BOTMASTER_NPP_ID + + # Handle manual board + if use_manual + attrs = %W[mappack_scores.id score_#{m} #{names} metanet_id player_id] + board = scores.where(id: manual) + else + case m + when 'hs', 'sr' # Handle standard boards + attrs = %W[mappack_scores.id score_#{m} #{names} metanet_id player_id] + attr_names << 'fraction' if frac + attrs << 'fraction' if frac + board = scores.where(!obsolete && !date ? "rank_#{m} IS NOT NULL" : '') + .where(date ? "UNIX_TIMESTAMP(`date`) <= #{date.to_i}" : '') + sfield = "`score_#{m}`" + sfield += (m == 'hs' ? ' - ' : ' + ') + '`fraction`' if frac + order = m == 'hs' ? 'DESC' : 'ASC' + board = board.order("#{sfield} #{order}", '`date` ASC') + #board = board.order("rank_#{m} ASC") + when 'gm' # Handle gold boards + attrs = [ + 'MIN(subquery.id) AS id', + 'MIN(score_gm) AS score_gm', + "MIN(#{names}) AS name", + 'subquery.metanet_id', + 'MIN(player_id) AS player_id' + ] + join = <<~SQL + INNER JOIN ( + SELECT metanet_id, MIN(gold) AS score_gm + FROM mappack_scores + WHERE highscoreable_id = #{id} AND highscoreable_type = '#{type}' + GROUP BY metanet_id + ) AS opt + ON mappack_scores.metanet_id = opt.metanet_id AND gold = score_gm + SQL + subquery = scores.select(:id, :score_gm, :player_id, :metanet_id).joins(join) + board = MappackScore.from(subquery).group(:metanet_id).order('score_gm', 'id') + end + end + + # Paginate + board = board.offset(offset) if offset > 0 + board = board.limit(truncate) if truncate > 0 && !pluck + return board if !pluck + + # Optionally pluck relevant fields only + board = board.joins("INNER JOIN `players` ON `players`.`id` = `player_id`") + .pluck(*attrs).map{ |s| attr_names.zip(s).to_h } + board.uniq!{ |s| s['metanet_id'] } if !obsolete && date + board = board.take(truncate) if truncate > 0 + + # We don't store cool and star flags, so compute them + cool_count = find_coolness(scores: board, date: date, board: m) if cools + board.each_with_index{ |s, i| + s['cool'] = true if cools && i < cool_count + # TODO: Add star here + } + board + end + + # Return a list of all dates where the top20 changed + def changes(board = 'hs') + hs = board == 'hs' + dates = [] + top20 = {} + scores.order(:date).pluck(:metanet_id, :score_hs, :score_sr, :date) + .each{ |metanet_id, score_hs, score_sr, date| + # Only PB's can produce a visible change + old = top20[metanet_id] + cur = hs ? score_hs : score_sr + pb = !old || (hs ? cur > old[:score] : cur < old[:score]) + next if !pb + + # Only changes in the top20 are visible + bottom = top20.max_by{ |id, h| [hs ? -h[:score] : h[:score], h[:date]] } + outside = top20.size >= 20 && (hs ? cur <= bottom[1][:score] : cur >= bottom[1][:score]) + next if outside + + # Save date and update top20 + dates << date + top20[metanet_id] = { score: cur, date: date } + top20.delete(bottom[0]) if top20.size > 20 + } + dates + end + + # Return scores in JSON format expected by N++ + def get_scores(qt = 0, metanet_id = nil, frac: false) + # Compute offset + m = qt == 2 ? 'sr' : 'hs' + offset = 0 + if qt == 1 && metanet_id + s = scores.where(metanet_id: metanet_id).where.not("rank_#{m}" => nil).first + r = s ? s["rank_#{m}"] : nil + n = completions + offset = [0, [r - 10, n - 20].min].max if r && n && r < n + end + + # Fetch leaderboard + dev_score = m == 'hs' ? dev_hs : dev_sr + count = dev_score ? 19 : 20 + board = leaderboard(m, truncate: count, metanet_id: metanet_id, offset: offset, frac: frac) + res = {} + + # Adjust scores + board.each_with_index.map{ |s, i| + s['score_hs'] -= s['fraction'] if frac && m == 'hs' + s['score_sr'] += s['fraction'] if frac && m == 'sr' + s['score_hs'] /= 60.0 if m == 'hs' + } + + # Inject DEV score + if dev_score + index = board.index{ |s| + m == 'hs' ? s['score_hs'] <= dev_score : s['score_sr'] >= dev_score + } + score = { "score_#{m}" => dev_score, 'name' => DEV_PLAYER_NAME, 'id' => -1, 'metanet_id' => -1 } + board.insert(index || -1, score) + end + + # Format response + # Note: + # In the replay ID field we use the lower bits to encode the real ID, and the + # the higher bits to encode the rank. That way scores with a lower rank will + # also have a lower replay ID, so when the game re-sorts ties by replay ID in + # the Global boards, they will actually be sorted properly according to the + # full precision of the frac scores, as opposed to just the milliseconds. We + # can later recover the correct ID in get_replay with basic bit manipulation. + res["scores"] = board.take(20).each_with_index.map{ |s, i| + { + "score" => (1000 * s["score_#{m}"]).round, + "rank" => offset + i, + "user_id" => s['metanet_id'].to_i, + "user_name" => s['name'].to_s.remove("\\"), + "replay_id" => pack_replay_id(i, s['id'].to_i) + } + } + + # ID fields + res["query_type"] = qt + res["#{self.class.to_s.remove("Mappack").downcase}_id"] = self.inner_id + + # Log + player = Player.find_by(metanet_id: metanet_id) + if !!player&.name + text = "#{player.name.to_s} requested #{self.name} leaderboards" + else + text = "#{self.name} leaderboards requested" + end + dbg(text) + + # Return leaderboards + res.to_json + end + + # Updates the rank and tied_rank fields of a specific mode, necessary when + # there's a new score (or when one is deleted later). + def update_ranks(board, frac: false) + # Fetch leaderboard sorted by score and date + return false if !['hs', 'sr'].include?(board) + list = leaderboard(board, truncate: 0, pluck: false, frac: frac) + sfield = "`score_#{board}`" + sfield += (board == 'hs' ? ' - ' : ' + ') + '`fraction`' if frac + list = list.pluck(:id, sfield) + return true if list.empty? + + # Compute ranks and tied ranks + tied_score = list[0][1] + tied_rank = 0 + list.each_with_index{ |s, i| + if board == 'hs' ? s[1] < tied_score : s[1] > tied_score + tied_rank = i + tied_score = s[1] + end + s << i << tied_rank + } + + # Update all rank and tied rank fields in a single SQL query + ranks = list.map{ |s| "WHEN #{s[0]} THEN #{s[2]}" }.join(' ') + ties = list.map{ |s| "WHEN #{s[0]} THEN #{s[3]}" }.join(' ') + sql = <<~SQL + UPDATE `mappack_scores` + SET `rank_#{board}` = CASE id #{ranks} END, + `tied_rank_#{board}` = CASE id #{ties} END + WHERE id IN (#{list.map(&:first).join(", ")}) + SQL + sql(sql) + + true + rescue => e + lex(e, "Failed to update ranks for #{self.class} #{id}") + false + end + + # Delete non-essential scores from the db: + # - They were never a hs / sr PB. + # - They aren't G++ / G-- PBs. + # If a player is not specified, do this operation for all players present + # in this highscoreable. + def delete_obsoletes(player = nil, frac: false) + # Fetch players to clean + if player + ids = [player.id] + else + ids = scores.group(:player_id).pluck(:player_id) + end + + # Delete non-essential scores for each player + deleted = ids.inject(0){ |sum, pid| + # Fetch player scores + query = scores.where(player_id: pid).order(:id) + score_hs = frac ? '`score_hs` - `fraction`' : :score_hs + score_sr = frac ? '`score_sr` + `fraction`' : :score_sr + list = query.pluck(:id, score_hs, score_sr, :gold) + + # Find gold PBs + gold_max = list.max_by(&:last).last + gold_min = list.min_by(&:last).last + + # Find scores which were once a hs / sr PB + pb_hs = -1 + pb_sr = 2 ** 16 + keepies = [] + list.each{ |id, score_hs, score_sr, gold| + keepies << id if score_hs > pb_hs || score_sr < pb_sr + pb_hs = score_hs if score_hs > pb_hs + pb_sr = score_sr if score_sr < pb_sr + } + + # Delete scores + query = query.where(rank_hs: nil, rank_sr: nil) + .where("gold < #{gold_max} AND gold > #{gold_min}") + .where.not(id: keepies.uniq) + .each(&:destroy) + sum + query.size + } + deleted + rescue => e + lex(e, 'Failed to delete obsolete scores.') + -1 + end + + # Verifies the integrity of a replay by generating the security hash and + # comparing it with the submitted one. + # + # The format used by N++ is: + # Hash = SHA1(MapHash + ScoreString) + # where: + # MapHash = SHA1(Pwd + MapData) [see Map#hash] + # Pwd = Hardcoded password (not present in outte's source code) + # MapData = Map's data [see Map#dump_level(hash: true)] + # ScoreString = (Score * 1000) rounded as an integer + # + # Notes: + # - Since this depends on both the score and the map data, a score cannot + # be submitted if either have been tampered with. + # - The modulo 2 ** 32 is to simulate 4-byte unsigned integer arithmetic, + # which is what N++ uses. Negative scores (which sometimes happen erroneously) + # then underflow, so we need to replicate this behaviour to match the hashes. + def _verify_replay(ninja_check, score, c: true, v: nil) + map_hash = _hash(c: c, v: v, pre: true) + return true if !map_hash + score = ((1000.0 * score / 60.0 + 0.5).floor % 2 ** 32).to_s + sha1(map_hash + score, c: c) == ninja_check + end + + def verify_replay(ninja_check, score, all: true) + (all ? versions : [version]).each{ |v| + #return true if _verify_replay(ninja_check, score, v: v, c: false) + return true if _verify_replay(ninja_check, score, v: v, c: true) + } + false + end +end + +class MappackLevel < ActiveRecord::Base + include Map + include MappackHighscoreable + include Levelish + has_many :mappack_scores, as: :highscoreable + has_many :mappack_hashes, as: :highscoreable, dependent: :delete_all + has_many :mappack_challenges, foreign_key: :level_id, dependent: :delete_all + has_one :score_correction, as: :highscoreable + belongs_to :mappack + belongs_to :userlevel + belongs_to :mappack_episode, foreign_key: :episode_id + alias_method :scores, :mappack_scores + alias_method :episode, :mappack_episode + alias_method :episode=, :mappack_episode= + alias_method :hashes, :mappack_hashes + alias_method :challenges, :mappack_challenges + alias_method :correction, :score_correction + create_enum(:tab, TABS_NEW.map{ |k, v| [k, v[:mode] * 7 + v[:tab]] }.to_h) + + def self.mappack + MappackLevel + end + + def self.vanilla + Level + end + + # Update all mappack level SHA1 hashes (for every version) + # 'pre' parameter is unused (as we force it to false, since we always want + # to recompute the level hashes), but is left there for compatibility with the + # Episode/Story versions of this method + def self.update_hashes(mappack: nil, pre: false) + total = 0 + list = self.where(mappack ? "mappack_id = #{mappack.id}" : '') + count = list.count + list.find_each.with_index{ |l, i| + dbg("Updating mappack hashes for level #{i + 1} / #{count}...", progress: true) + total += l.update_hashes(pre: false) + } + Log.clear + total + end + + def versions + MappackData.where(highscoreable_id: id) + .where("tile_data IS NOT NULL OR object_data IS NOT NULL") + .distinct + .order(:version) + .pluck(:version) + end + + # Return the tile data, optionally specify a version, otherwise pick last + # Can also return all available versions as a hash + def tile_data(version: nil, all: false) + data = MappackData.where(highscoreable_id: id) + .where(version ? "version <= #{version}" : '') + .where.not(tile_data: nil) + return nil if data.empty? + + if all + data.pluck(:version, :tile_data).to_h + else + data.order(version: :desc).first.tile_data + end + rescue + nil + end + + # Return the object data, optionally specify a version, otherwise pick last + # Can also return all available versions as a hash + def object_data(version: nil, all: false) + data = MappackData.where(highscoreable_id: id) + .where(version ? "version <= #{version}" : '') + .where.not(object_data: nil) + return nil if data.empty? + + if all + data.pluck(:version, :object_data).to_h + else + data.order(version: :desc).first.object_data + end + rescue + nil + end + + # Compare hashes generated by Ruby and STB + def compare_hashes + # Prepare map data to hash + map_data = dump_level(hash: true) + return true if map_data.nil? + to_hash = PWD + map_data[0xB8..-1] + + # Hash + hash_c = sha1(to_hash, c: true) + hash_ruby = sha1(to_hash, c: false) + return false if !hash_c || !hash_ruby + + return hash_c == hash_ruby + end +end + +class MappackEpisode < ActiveRecord::Base + include MappackHighscoreable + include Episodish + has_many :mappack_levels, foreign_key: :episode_id + has_many :mappack_scores, as: :highscoreable + has_many :mappack_hashes, as: :highscoreable, dependent: :delete_all + has_many :mappack_scores_tweaks, foreign_key: :episode_id + belongs_to :mappack + belongs_to :mappack_story, foreign_key: :story_id + alias_method :levels, :mappack_levels + alias_method :scores, :mappack_scores + alias_method :hashes, :mappack_hashes + alias_method :story, :mappack_story + alias_method :story=, :mappack_story= + alias_method :tweaks, :mappack_scores_tweaks + create_enum(:tab, TABS_NEW.map{ |k, v| [k, v[:mode] * 7 + v[:tab]] }.to_h) + + def self.mappack + MappackEpisode + end + + def self.vanilla + Episode + end + + # Update all mappack episode SHA1 hashes (for every version) + def self.update_hashes(mappack: nil, pre: false) + total = 0 + list = self.where(mappack ? "mappack_id = #{mappack.id}" : '') + count = list.count + list.find_each.with_index{ |e, i| + dbg("Updating mappack hashes for episode #{i + 1} / #{count}...", progress: true) + total += e.update_hashes(pre: pre) + } + Log.clear + total + end + + def versions + MappackData.where("highscoreable_id DIV 5 = #{id}") + .where("tile_data IS NOT NULL OR object_data IS NOT NULL") + .distinct + .order(:version) + .pluck(:version) + end + + # Computes the episode's hash, which the game uses for integrity verifications + # If 'pre', take the precomputed level hashes, otherwise compute them + def _hash(c: false, v: nil, pre: false) + if pre && c + stored = saved_hash(v: v) + return stored if stored + end + hashes = levels.order(:id).map{ |l| l._hash(c: c, v: v, pre: pre) }.compact + hashes.size < 5 ? nil : hashes.join + end +end + +class MappackStory < ActiveRecord::Base + include MappackHighscoreable + include Storyish + has_many :mappack_episodes, foreign_key: :story_id + has_many :mappack_scores, as: :highscoreable + has_many :mappack_hashes, as: :highscoreable, dependent: :delete_all + belongs_to :mappack + alias_method :scores, :mappack_scores + alias_method :episodes, :mappack_episodes + alias_method :hashes, :mappack_hashes + create_enum(:tab, TABS_NEW.map{ |k, v| [k, v[:mode] * 7 + v[:tab]] }.to_h) + + def self.mappack + MappackStory + end + + def self.vanilla + Story + end + + # Update all mappack story SHA1 hashes (for every version) + def self.update_hashes(mappack: nil, pre: false) + total = 0 + list = self.where(mappack ? "mappack_id = #{mappack.id}" : '') + count = list.count + list.find_each.with_index{ |s, i| + dbg("Updating mappack hashes for story #{i + 1} / #{count}...", progress: true) + total += s.update_hashes(pre: pre) + } + Log.clear + total + end + + def versions + MappackData.where("highscoreable_id DIV 25 = #{id}") + .where("tile_data IS NOT NULL OR object_data IS NOT NULL") + .distinct + .order(:version) + .pluck(:version) + end + + # Computes the story's hash, which the game uses for integrity verifications + # If 'pre', take the precomputed level hashes, otherwise compute them + def _hash(c: false, v: nil, pre: false) + if pre && c + stored = saved_hash(v: v) + return stored if stored + end + hashes = levels.order(:id).map{ |l| l._hash(c: c, v: v, pre: pre) }.compact + return nil if hashes.size < 25 + hashes.inject(0.chr * 20){ |working, hash| sha1(working + hash, c: c) } + end +end + +class MappackScore < ActiveRecord::Base + include Scorish + has_one :mappack_demo, foreign_key: :id, dependent: :destroy + belongs_to :player + belongs_to :highscoreable, polymorphic: true + belongs_to :mappack + after_destroy :cleanup + alias_method :demo, :mappack_demo + alias_method :demo=, :mappack_demo= + create_enum(:tab, TABS_NEW.map{ |k, v| [k, v[:mode] * 7 + v[:tab]] }.to_h) + + # TODO: Add integrity checks and warnings in Demo.parse + + # Verify, parse and save a submitted run, respond suitably + def self.add(code, query, req = nil) + # Parse player ID + uid = query['user_id'].to_i + if uid <= 0 || uid >= 10000000 + alert("Invalid player (ID #{uid}) submitted a score") + return + end + + # Apply blacklist + name = "ID:#{uid}" + if HACKERS.key?(uid) || CHEATERS.key?(uid) + name = (HACKERS[uid] || CHEATERS[uid]).first + alert("Blacklisted player #{name} submitted a score", discord: true) + return + end + + # Parse type + type = TYPES.find{ |_, h| query.key?("#{h[:name].downcase}_id") }[1] rescue nil + if type.nil? + alert("Score submitted: Type not found") + return + end + id_field = "#{type[:name].downcase}_id" + + # Craft response fields + res = { + 'better' => 0, + 'score' => query['score'].to_i, + 'rank' => -1, + 'replay_id' => -1, + 'user_id' => uid, + 'qt' => query['qt'].to_i, + id_field => query[id_field].to_i + } + + # Find player + player = Player.find_or_create_by(metanet_id: uid) + name = !player.name.nil? ? player.name : "ID:#{player.metanet_id}" + + # Find mappack + mappack = Mappack.find_by(code: code) + if mappack.nil? + alert("Score submitted by #{name}: Mappack '#{code}' not found") + return + end + frac = mappack.fractional + + # Find highscoreable + sid = query[id_field].to_i + h = "Mappack#{type[:name]}".constantize.find_by(mappack: mappack, inner_id: sid) + if h.nil? + # If highscoreable not found, and forwarding is disabled, return nil + if !CLE_FORWARD + alert("Score submitted by #{name}: #{type[:name]} ID:#{sid} for mappack '#{code}' not found") + return + end + + # If highscoreable not found, but forwarding is enabled, forward to Metanet + # Also, try to update the corresponding Metanet scores in outte (in parallel) + res = forward(req) + _thread(release: true) do + h = (sid >= MIN_ID ? Userlevel : type[:name].constantize).find_by(id: sid) + h.update_scores(fast: true) if h + end if !res.nil? + return res + end + frac &&= h.is_level? + action_inc('http_submit') + + # Parse demos and compute new scores + demos = Demo.parse(query['replay_data'], type[:name]) + score_hs = (60.0 * query['score'].to_i / 1000.0).round + score_sr = demos.map(&:size).sum + score_sr /= 2 if h.mode == 1 # Coop demos contain 2 sets of inputs + + # Tweak level scores submitted within episode runs + score_hs_orig = score_hs + if type[:name] == 'Level' + score_hs = MappackScoresTweak.tweak(score_hs, player, h, Demo.parse_header(query['replay_data'])) + if score_hs.nil? + alert("Tweaking of score submitted by #{name} to #{h.name} failed", discord: true) + score_hs = score_hs_orig + end + end + + # Compute gold count from hs and sr scores + goldf = MappackScore.gold_count(type[:name], score_hs, score_sr) + gold = goldf.round # Save floating value for later + + # Verify replay integrity by checking security hash + legit = h.verify_replay(query['ninja_check'], score_hs_orig) + if !legit + _thread do + alert("#{name} submitted a score to #{h.name} with an invalid security hash", discord: true) + end if WARN_INTEGRITY + return res.to_json if INTEGRITY_CHECKS + end + + # Verify score integrity by performing gold check + corrupt = type[:name] != 'Story' && !MappackScore.verify_gold(goldf) + corrupt ||= h.gold && gold > h.gold || gold < 0 + + # Verify additional mappack-wise requirements + return if !mappack.check_requirements(demos) + + # Level also published as a userlevel, patch level ID and score and forward to Metanet + # It's important to do it here because it happens after: + # - Tweaking, so episodes continue working normally. + # - Patching corrupt scores, so we don't send those to Metanet. + if h.is_level? && h.userlevel && h.forward + return res.to_json if corrupt + new_score = score_hs / 60.0 + new_score -= 2.0 * h.correction.gold.to_i if h.correction + req.body = req.body.sub(/level_id.+?\K\d+/m, h.userlevel.id.to_s) + .sub(/score.+?\K\d+/m, (1000 * new_score).round.to_s) + res = forward(req) + _thread(release: true){ h.userlevel.update_scores(fast: true) } if res + return res&.sub(/level_id.+?\K\d+/m, h.inner_id.to_s) + end + + # Conmpute fractional score using NSim + patched = false + if frac + sim_res = NSim.run(h.dump_level, [Demo.encode(demos)]){ |nsim| + { score: nsim.score, frac: nsim.frac } + } + fraction = sim_res[:frac] || 1 + + # If the sent hs score is corrupt, might as well see if we can patch it now + if corrupt && sim_res[:score] + new_hs = (60.0 * sim_res[:score]).round + new_goldf = MappackScore.gold_count('Level', new_hs, score_sr) + if MappackScore.verify_gold(new_goldf) + score_hs = new_hs + gold = new_goldf.round + patched = true + end + end + + # Modify response + res['score'] = (1000.0 * (score_hs - fraction) / 60.0).round + end + + # Fetch old PB's + scores = MappackScore.where(highscoreable: h, player: player) + score_hs_max = scores.maximum(frac ? '`score_hs` - `fraction`' : :score_hs) + score_sr_min = scores.minimum(frac ? '`score_sr` + `fraction`' : :score_sr) + gold_max = scores.maximum(:gold) + gold_min = scores.minimum(:gold) + + # Determine if new score is better and has to be saved + hs = !score_hs_max || (frac ? score_hs - fraction : score_hs) > score_hs_max + sr = !score_sr_min || (frac ? score_sr + fraction : score_sr) < score_sr_min + gp = !gold_max || gold > gold_max + gm = !gold_min || gold < gold_min + scores.update_all(rank_hs: nil, tied_rank_hs: nil) if hs + scores.update_all(rank_sr: nil, tied_rank_sr: nil) if sr + res['better'] = hs || sr ? 1 : 0 + + # If score improved in either mode + if hs || sr || gp || gm + # Create new score and demo + score = MappackScore.create( + rank_hs: hs ? -1 : nil, + tied_rank_hs: hs ? -1 : nil, + rank_sr: sr ? -1 : nil, + tied_rank_sr: sr ? -1 : nil, + score_hs: score_hs, + score_sr: score_sr, + mappack_id: mappack.id, + tab: h.tab, + player: player, + metanet_id: player.metanet_id, + highscoreable: h, + date: Time.now.strftime(DATE_FORMAT_MYSQL), + gold: gold, + fraction: frac ? fraction : 1, + simulated: frac + ) + MappackDemo.create(id: score.id, demo: Demo.encode(demos)) + + # Verify hs score integrity by checking calculated gold count + if corrupt + _thread do + alert("#{patched ? 'Auto-patched p' : 'P'}otentially incorrect hs score submitted by #{name} in #{h.name} (ID #{score.id})", discord: true) + end + end + + # Warn if the score submitted failed the map data integrity checks, and save it + # to analyze it later (and possibly polish the hash algorithm) + BadHash.find_or_create_by(id: score.id).update( + npp_hash: query['ninja_check'], + score: score_hs_orig + ) if !legit + + # Warn if mappack version is outdated + v1 = (req.root[/\d+$/i] || 1).to_i + v2 = mappack.version + if WARN_VERSION && v1 != v2 + _thread do + alert("#{name} submitted a score to #{h.name} with an incorrect mappack version (#{v1} vs #{v2})", discord: true) + end + end + end + + # Update ranks and completions if necessary + h.update_ranks('hs', frac: frac) if hs + h.update_ranks('sr', frac: frac) if sr + h.update(completions: h.scores.distinct.count(:player_id)) if hs || sr || gp || gm + + # Delete obsolete scores of the player in the highscoreable + h.delete_obsoletes(player, frac: frac) + + # Fetch player's best scores, to fill remaining response fields + best_hs = player.find_pb(h, 'hs', frac: frac) + best_sr = player.find_pb(h, 'sr', frac: frac) + rank_hs = best_hs.rank_hs rescue nil + rank_sr = best_sr.rank_sr rescue nil + replay_id_hs = best_hs.id rescue nil + replay_id_sr = best_sr.id rescue nil + res['rank'] = frac ? (1000000.0 * fraction / 60.0).round : rank_hs || rank_sr || -1 + res['replay_id'] = replay_id_hs || replay_id_sr || -1 + + # Finish + dbg("#{name} submitted a score to #{h.name}") + return res.to_json + rescue => e + lex(e, "Failed to add score submitted by #{name} to mappack '#{code}'") + return + end + + # Respond to a request for leaderboards + def self.get_scores(code, query, req = nil) + name = "?" + + # Parse type + type = TYPES.find{ |_, h| query.key?("#{h[:name].downcase}_id") }[1] rescue nil + if type.nil? + alert("Getting scores: Type not found") + return + end + sid = query["#{type[:name].downcase}_id"].to_i + name = "ID:#{sid}" + + # Find mappack + mappack = Mappack.find_by(code: code) + if mappack.nil? + alert("Getting scores: Mappack '#{code}' not found") + return + end + + # Find highscoreable + h = "Mappack#{type[:name]}".constantize.find_by(mappack: mappack, inner_id: sid) + if h.nil? + # Highscoreable not from this mappack, forward to Metanet + return forward(req) if CLE_FORWARD + alert("Getting scores: #{type[:name]} #{name} for mappack '#{code}' not found") + return + elsif h.is_level? && h.userlevel && h.forward + # Level also published as a userlevel, swap level ID and forward to Metanet + req.query.merge!({'level_id' => h.userlevel.id.to_s}) + res = forward(req) + _thread(release: true){ h.userlevel.update_scores(fast: true) } if res + if res && h.correction&.gold + res = JSON.parse(res) + res['scores'].each{ |entry| entry['score'] += 2000 * h.correction.gold } + res['userInfo']['my_score'] += 2000 * h.correction.gold if res['userInfo'] + res = res.to_json + end + return res&.sub(/level_id.+?\K\d+/m, h.inner_id.to_s) + end + frac = mappack.fractional && h.is_level? + name = h.name + + # Get scores + action_inc('http_scores') + return h.get_scores(query['qt'].to_i, query['user_id'].to_i, frac: frac) + rescue => e + lex(e, "Failed to get scores for #{name} in mappack '#{code}'") + return + end + + # Respond to a request for a replay + def self.get_replay(code, query, req = nil) + # Integrity checks + if !query.key?('replay_id') + alert("Getting replay: Replay ID not provided") + return + end + rank, replay_id = unpack_replay_id(query['replay_id'].to_i) + + # Parse type (no type = level) + type = TYPES.find{ |_, h| query['qt'].to_i == h[:qt] }[1] rescue nil + if type.nil? + alert("Getting replay: Type #{query['qt'].to_i} is incorrect") + return + end + + # Find mappack + mappack = Mappack.find_by(code: code) + if mappack.nil? + alert("Getting replay: Mappack '#{code}' not found") + return + end + + # Find player (for logging purposes only) + player = Player.find_by(metanet_id: query['user_id'].to_i) + name = !player.nil? ? player.name : "ID:#{query['user_id']}" + + # Find score and perform integrity checks + score = MappackScore.find_by(id: replay_id) + if score.nil? + return forward(req) if CLE_FORWARD + alert("Getting replay: Score with ID #{replay_id} not found") + return + end + + if score.highscoreable.mappack.code != code + return forward(req) if CLE_FORWARD + alert("Getting replay: Score with ID #{replay_id} is not from mappack '#{code}'") + return + end + + if score.highscoreable.basetype != type[:name] + return forward(req) if CLE_FORWARD + alert("Getting replay: Score with ID #{replay_id} is not from a #{type[:name].downcase}") + return + end + + # Do not return replays for protected boards + return nil if score.highscoreable.is_protected? + + # Find replay + demo = score.demo + if demo.nil? || demo.demo.nil? + alert("Getting replay: Replay with ID #{replay_id} not found") + return + end + + # Return replay + dbg("#{name} requested replay #{replay_id} (#{query['replay_id'].to_i})") + action_inc('http_replay') + score.dump_replay(rank) + rescue => e + lex(e, "Failed to get replay with ID #{replay_id} from mappack '#{code}'") + return + end + + # Manually change a score, given either: + # - A player and a highscoreable, in which case, his current hs PB will be taken + # - An ID, in which case that specific score will be chosen + # It performs score validation via gold check before changing it + def self.patch_score(id, highscoreable, player, score, silent: false, frac: false, output: nil) + # Find score + if !id.nil? # If ID has been provided + s = MappackScore.find_by(id: id) + perror("Mappack score of ID #{id} not found") if !s + highscoreable = s.highscoreable + player = s.player + scores = MappackScore.where(highscoreable: highscoreable, player: player) + else # If highscoreable and player have been provided + perror("#{highscoreable.name} does not belong to a mappack") if !highscoreable.is_mappack? + scores = MappackScore.where(highscoreable: highscoreable, player: player) + perror("#{player.name} does not have a score in #{highscoreable.name}") if scores.empty? + s = scores.where.not(rank_hs: nil).first + perror("#{player.name}'s leaderboard score in #{highscoreable.name} not found") if !s + end + + # Compute score and frac with NSim if not specified + if !score + perror("Mappack score #{s.id} has no associated demo.") if !s.demo&.demo + res = NSim.run(highscoreable.dump_level, [s.demo.demo]){ |nsim| { score: nsim.score, frac: nsim.frac } } + perror("ntrace failed to compute correct score") if !res[:score] || !res[:frac] + s.update(fraction: res[:frac], simulated: true) + score = res[:score] + end + + # Score integrity checks + new_score = (score * 60).round + gold = MappackScore.gold_count(highscoreable.type, new_score, s.score_sr) + perror("The inferred gold count is incorrect") if gold.round < 0 || gold.round > highscoreable.gold + perror("That score is incompatible with the framecount") if !MappackScore.verify_gold(gold) && !highscoreable.type.include?('Story') + + # Change score and new computed gold count + old_score = s.score_hs.to_f / 60.0 + perror("#{player.name}'s score (#{s.id}) in #{highscoreable.name} is already #{'%.3f' % old_score}") if s.score_hs == new_score + s.update(score_hs: new_score, gold: gold.round) + + # Update ranks and remove obsolete scores potentially derived from the change + player.update_rank(highscoreable, 'hs', frac: frac) + highscoreable.delete_obsoletes(player, frac: frac) + + # Log + str = "Patched #{player.name}'s score (#{s.id}) in #{highscoreable.name} from #{'%.3f' % old_score} to #{'%.3f' % score}" + output << str if output + succ(str) + rescue => e + lex(e, 'Failed to patch score') + rescue OutteError + raise unless silent + end + + # Calculate gold count from hs and sr scores + # We return a FLOAT, not an integer. See the next function for details. + def self.gold_count(type, score_hs, score_sr) + case type.remove('Mappack') + when 'Level' + tweak = 1 + when 'Episode' + tweak = 5 + when 'Story' + tweak = 25 + else + alert("Incorrect type when calculating gold count") + tweak = 0 + end + (score_hs + score_sr - 5400 - tweak).to_f / 120 + end + + # Verify if floating point gold count is close enough to an integer. + # + # Context: Sometimes the hs score is incorrectly calculated by the game, + # and we can use this as a test to find incorrect scores, if the calculated + # gold count is not exactly an integer. + def self.verify_gold(gold) + (gold - gold.round).abs < 0.001 + end + + # Perform the gold check (see the 2 methods above) for every score in the + # database, returning the scores failing the check. + def self.gold_check(id: MIN_REPLAY_ID, mappack: nil, strict: false) + self.joins('INNER JOIN mappack_levels ON mappack_levels.id = highscoreable_id') + .joins('INNER JOIN players on players.id = player_id') + .where("highscoreable_type = 'MappackLevel' AND mappack_scores.id >= #{id}") + .where(mappack ? "mappack_scores.mappack_id = #{mappack.id}" : '') + .where(strict ? "rank_hs < 20 OR rank_sr < 20" : '') + .having('remainder > 0.001 OR mappack_scores.gold < 0 OR mappack_scores.gold > mappack_levels.gold') + .order('highscoreable_id', 'mappack_scores.id') + .pluck( + 'mappack_levels.name', + 'SUBSTRING(players.name, 1, 16)', + 'mappack_scores.id', + 'score_hs / 60.0', + 'rank_hs', + 'rank_sr', + 'gold', + 'mappack_levels.gold', + 'ABS(MOD((score_hs + score_sr - 5401) / 120, 1)) AS remainder' + ).map{ |row| row[0..-4] + ["#{'%3d' % row[-3]} / #{'%3d' % row[-2]}"] } + rescue => e + lex(e, 'Failed to compute gold check.') + [['Error', 'Error', 'Error', 'Error', 'Error', 'Error', 'Error']] + end + + # Update the completion count for each mappack highscoreable, should only + # need to be executed once, or occasionally, to seed them for the first + # time. From then on, the score submission function updates the figure. + def self.update_completions(mappack: nil) + bench(:start) if BENCHMARK + [MappackLevel, MappackEpisode, MappackStory].each{ |type| + type.where(mappack ? "mappack_id = #{mappack.id}" : '') + .update_all(completions: 0) + self.where(highscoreable_type: type).where.not(rank_hs: nil) + .where(mappack ? "mappack_id = #{mappack.id}" : '') + .group(:highscoreable_id) + .order('count(highscoreable_id)', 'highscoreable_id') + .count(:highscoreable_id) + .group_by{ |id, count| count } + .map{ |count, ids| [count, ids.map(&:first)] } + .each{ |count, ids| + type.where(id: ids).update_all(completions: count) + } + bench(:step) if BENCHMARK + } + end + + def archive + self + end + + def gold_count + self.class.gold_count(highscoreable.type, score_hs, score_sr) + end + + def verify_gold + self.class.verify_gold(gold_count) + end + + # Dumps demo data in the format N++ uses for server communications + def dump_demo + demos = Demo.decode(demo.demo, true) + highscoreable.dump_demo(demos) + rescue => e + lex(e, "Failed to dump demo with ID #{id}") + nil + end + + # Dumps replay data (header + compressed demo data) in format used by N++ + def dump_replay(rank = 0) + type = TYPES[highscoreable.basetype] + replay_id = pack_replay_id(rank, id) + + # Build header + replay = [type[:rt]].pack('L<') # Replay type (0 lvl/sty, 1 ep) + replay << [replay_id].pack('L<') # Replay ID + replay << [highscoreable.inner_id].pack('L<') # Level ID + replay << [player.metanet_id].pack('L<') # User ID + + # Append replay and return + inputs = dump_demo + return if inputs.nil? + replay << Zlib::Deflate.deflate(inputs, 9) + replay + rescue => e + lex(e, "Failed to dump replay with ID #{id}") + return + end + + # Generate an attract file with this map and replay + def dump_attract(silent: false) + map_data = highscoreable.dump_level(query: true) + demo_data = dump_demo + [map_data.size, demo_data.size, map_data, demo_data].pack('L<2a*a*') + rescue => e + lex(e, "Failed to dump attract file with ID #{id}") unless silent + return + end + + # Perform cleanup after destroying a score (delete demo, and update ranks if necessary) + def cleanup + # Demo is deleted automatically by the :dependent option + return if !rank_hs && !rank_sr + h = highscoreable_type.constantize.find(highscoreable_id) + p = Player.find(player_id) + frac = h.mappack.fractional && h.is_level? + p.update_rank(h, 'hs', frac: frac) if !!rank_hs + p.update_rank(h, 'sr', frac: frac) if !!rank_sr + rescue => e + lex(e, "Destroy cleanup for mappack score #{id} failed") + end + + def compare_hashes + # Prepare map data to hash + map_data = highscoreable.dump_level(hash: true) + return true if map_data.nil? + to_hash = PWD + map_data[0xB8..-1] + + # Hash 1 + hash_c = sha1(to_hash, c: true) + hash_ruby = sha1(to_hash, c: false) + return false if !hash_c || !hash_ruby + + # Hash 2 + score = (1000.0 * score_hs.to_i / 60.0).round.to_s + hash_c = sha1(hash_c + score, c: true) + hash_ruby = sha1(hash_ruby + score, c: false) + return false if !hash_c || !hash_ruby + + return hash_c == hash_ruby + end + + # Return the score adjusted with the fractional part, if it exists + def frac(board) + return nil if !['hs', 'sr'].include?(board) || fraction == 1 + board == 'hs' ? score_hs - fraction : score_sr + fraction + end + + # Calculate the interpolated fractional frame using Sim's tool + def seed_fraction + # Map or demo not available, cannot compute + if !highscoreable || !demo&.demo + update(fraction: 1) + return :lost + end + + frac = NSim.run(highscoreable.dump_level, [demo.demo]){ |nsim| nsim.frac } + update(fraction: frac || 1, simulated: true) + return frac ? :good : :bad + rescue => e + lex(e, "Fraction computation failed for mappack score #{id}") + end +end + +class MappackDemo < ActiveRecord::Base + include Demoish + belongs_to :mappack_score, foreign_key: :id + alias_method :score, :mappack_score + alias_method :score=, :mappack_score= + + # Delete orphaned demos (demos without a corresponding score) + def self.sanitize + orphans = joins('LEFT JOIN mappack_scores ON mappack_demos.id = mappack_scores.id') + .where('mappack_scores.id IS NULL') + count = orphans.count + orphans.delete_all + count + end +end + +# N++ sometimes submits individual level scores incorrectly when submitting +# episode runs. The fix required is to add the sum of the lengths of the +# runs for the previous levels in the episode, until we reach a level whose +# score was correct. + +# Since all 5 level scores are not submitted in parallel, but in sequence, this +# table temporarily holds the adjustment, which will be updated and applied with +# each level, until all 5 are done, and then we delete it. +class MappackScoresTweak < ActiveRecord::Base + belongs_to :player + belongs_to :mappack_episode, foreign_key: :episode_id + alias_method :episode, :mappack_episode + alias_method :episode=, :mappack_episode= + + # Returns the score if success, nil otherwise + def self.tweak(score, player, level, header) + # Not in episode, not tweaking + return score if header.type != 1 + + # Create or fetch tweak + index = level.inner_id % 5 + if index == 0 + tw = self.find_or_create_by(player: player, episode_id: level.episode.id) + tw.update(tweak: 0, index: 0) # Initialize tweak + else + tw = self.find_by(player: player, episode_id: level.episode.id) + if tw.nil? # Tweak should exist + alert("Tweak for #{player.name}'s #{level.episode.name} run should exit") + return nil + end + end + + # Ensure tweak corresponds to the right level + if tw.index != index + alert("Tweak for #{player.name}'s #{level.episode.name} has index #{tw.index}, should be #{index}") + return nil + end + + # Tweak if necessary + if header.id == level.inner_id # Tweak + score += tw.tweak + tw.tweak += header.framecount - 1 + tw.save + else # Don't tweak, reset tweak for later + tw.update(tweak: header.framecount - 1) + end + + # Prepare tweak for next level + index < 4 ? tw.update(index: index + 1) : tw.destroy + + # Tweaked succesfully + return score + rescue => e + lex(e, 'Failed to tweak score') + nil + end +end + +# A table to store all the calculated integrity hashes that do not match the +# submitted one. This could mean one of two things: +# 1) The score has been submitted with a different level, or has been cheated. +# Either way, it needs to be checked. +# 2) Our SHA1 algo doesn't match the one used by N++, so we want to polish that. +# This is currently happening sometimes (Edit: Not anymore) +class BadHash < ActiveRecord::Base + # Remove orphaned bad hashes (missing corresponding mappack score) + def self.sanitize + orphans = joins('LEFT JOIN mappack_scores ON bad_hashes.id = mappack_scores.id') + .where('mappack_scores.id IS NULL') + count = orphans.count + orphans.delete_all + count + end +end + +# This table stores the Discord IDs for the channels that are dedicated to each +# mappack, so that decisions (such as default mappack for commands) can be based +# on this information +class MappackChannel < ActiveRecord::Base + belongs_to :mappack +end + +# This table stores all the precomputed SHA1 hashes for all versions of every +# mappack level, episode and story. This is used for replay integrity validation, +# and is actually what takes the most time. +class MappackHash < ActiveRecord::Base + belongs_to :highscoreable, polymorphic: true + + # Update all hashes for all mappack highscoreables + def self.seed(mappack: nil, types: [Level, Episode, Story]) + total = 0 + types.each{ |t| total += t.mappack.update_hashes(mappack: mappack, pre: true) } + total + end +end + +class MappackChallenge < ActiveRecord::Base + include Challengish + belongs_to :mappack_level, foreign_key: :level_id + alias_method :level, :mappack_level + alias_method :level=, :mappack_level= +end + +# Done loading file +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) diff --git a/src/maps.rb b/src/maps.rb new file mode 100644 index 00000000..b6eb8b2a --- /dev/null +++ b/src/maps.rb @@ -0,0 +1,2792 @@ +# This file contains the generic Map module, that encapsulates a lot of the +# properties of an N++ map (map data parsing and formatting, screenshot, +# trace and animation generation, etc). Three classes implement this module: +# Level, Userlevel, and MappackLevel (the former by first calling .map). +# It also contains the Palette class, each representing an in-game palette and +# used to support custom palettes. It also contains all relevant file information. + +$load_time = Time.now + +#require 'chunky_png' +require 'oily_png' # C wrapper for ChunkyPNG +require 'fileutils' +require 'gifenc' # Own gem to encode and decode GIFs +require 'matplotlib/pyplot' +require 'stringio' +require 'zlib' + +module Map + # In-game entity IDs + ID_NINJA = 0 + ID_MINE = 1 + ID_GOLD = 2 + ID_EXIT = 3 + ID_EXIT_SWITCH = 4 + ID_DOOR_REGULAR = 5 + ID_DOOR_LOCKED = 6 + ID_DOOR_LOCKED_SWITCH = 7 + ID_DOOR_TRAP = 8 + ID_DOOR_TRAP_SWITCH = 9 + ID_LAUNCHPAD = 10 + ID_ONEWAY = 11 + ID_DRONE_CHAINGUN = 12 + ID_DRONE_LASER = 13 + ID_DRONE_ZAP = 14 + ID_DRONE_CHASER = 15 + ID_FLOORGUARD = 16 + ID_BOUNCEBLOCK = 17 + ID_ROCKET = 18 + ID_GAUSS = 19 + ID_THWUMP = 20 + ID_TOGGLE_MINE = 21 + ID_EVIL_NINJA = 22 + ID_LASER_TURRET = 23 + ID_BOOST_PAD = 24 + ID_DEATHBALL = 25 + ID_MICRODRONE = 26 + ID_MINI = 27 + ID_SHOVE_THWUMP = 28 + + # pref - Drawing preference (for overlaps): lower = more to the front + # att - Number of object attributes in the old format + # old - ID in the old format, '-1' if it didn't exist + # pal - Index at which the colors of the object start in the palette image + # states - Number of different sprites + OBJECTS = { + ID_NINJA => { pref: 4, att: 2, old: 0, pal: 6, states: 1, name: 'ninja' }, + ID_MINE => { pref: 22, att: 2, old: 1, pal: 10, states: 3, name: 'mine' }, + ID_GOLD => { pref: 21, att: 2, old: 2, pal: 14, states: 1, name: 'gold' }, + ID_EXIT => { pref: 25, att: 4, old: 3, pal: 17, states: 2, name: 'exit door' }, + ID_EXIT_SWITCH => { pref: 20, att: 0, old: -1, pal: 25, states: 2, name: 'exit switch' }, + ID_DOOR_REGULAR => { pref: 19, att: 3, old: 4, pal: 30, states: 2, name: 'regular door' }, + ID_DOOR_LOCKED => { pref: 28, att: 5, old: 5, pal: 31, states: 2, name: 'locked door' }, + ID_DOOR_LOCKED_SWITCH => { pref: 27, att: 0, old: -1, pal: 33, states: 2, name: 'locked door switch' }, + ID_DOOR_TRAP => { pref: 29, att: 5, old: 6, pal: 39, states: 2, name: 'trap door' }, + ID_DOOR_TRAP_SWITCH => { pref: 26, att: 0, old: -1, pal: 41, states: 2, name: 'trap door switch' }, + ID_LAUNCHPAD => { pref: 18, att: 3, old: 7, pal: 47, states: 1, name: 'launch pad' }, + ID_ONEWAY => { pref: 24, att: 3, old: 8, pal: 49, states: 1, name: 'one-way platform' }, + ID_DRONE_CHAINGUN => { pref: 16, att: 4, old: 9, pal: 51, states: 1, name: 'chaingun drone' }, + ID_DRONE_LASER => { pref: 17, att: 4, old: 10, pal: 53, states: 1, name: 'laser drone' }, + ID_DRONE_ZAP => { pref: 15, att: 4, old: 11, pal: 57, states: 1, name: 'zap drone' }, + ID_DRONE_CHASER => { pref: 14, att: 4, old: 12, pal: 59, states: 1, name: 'seeker drone' }, + ID_FLOORGUARD => { pref: 13, att: 2, old: 13, pal: 61, states: 1, name: 'floorguard' }, + ID_BOUNCEBLOCK => { pref: 3, att: 2, old: 14, pal: 63, states: 1, name: 'bounce block' }, + ID_ROCKET => { pref: 8, att: 2, old: 15, pal: 65, states: 1, name: 'rocket launcher' }, + ID_GAUSS => { pref: 9, att: 2, old: 16, pal: 69, states: 1, name: 'gauss turret' }, + ID_THWUMP => { pref: 6, att: 3, old: 17, pal: 74, states: 1, name: 'thwump' }, + ID_TOGGLE_MINE => { pref: 23, att: 2, old: 18, pal: 10, states: 3, name: 'toggle mine' }, + ID_EVIL_NINJA => { pref: 5, att: 2, old: 19, pal: 77, states: 1, name: 'evil ninja' }, + ID_LASER_TURRET => { pref: 7, att: 4, old: 20, pal: 79, states: 1, name: 'laser turret' }, + ID_BOOST_PAD => { pref: 1, att: 2, old: 21, pal: 81, states: 1, name: 'boost pad' }, + ID_DEATHBALL => { pref: 10, att: 2, old: 22, pal: 83, states: 1, name: 'deathball' }, + ID_MICRODRONE => { pref: 12, att: 4, old: 23, pal: 57, states: 1, name: 'micro drone' }, + ID_MINI => { pref: 11, att: 2, old: 24, pal: 86, states: 1, name: 'mini' }, + ID_SHOVE_THWUMP => { pref: 2, att: 2, old: 25, pal: 88, states: 3, name: 'shove thwump' } + } + + OBJECT_COUNT = 40 + + # Objects that do not admit sprite rotations at all + # Note: Shove thwumps admit sprite rotation for when they are in toggling state + ID_LIST_FIXED = [ + ID_NINJA, ID_MINE, ID_GOLD, ID_EXIT, + ID_EXIT_SWITCH, ID_DOOR_LOCKED_SWITCH, ID_DOOR_TRAP_SWITCH, ID_FLOORGUARD, + ID_BOUNCEBLOCK, ID_ROCKET, ID_GAUSS, ID_TOGGLE_MINE, + ID_EVIL_NINJA, ID_BOOST_PAD, ID_DEATHBALL, ID_MINI + ] + + # Objects that admit diagonal sprite rotations + ID_LIST_DIAG = [ID_LAUNCHPAD, ID_ONEWAY, ID_LASER_TURRET] + + # Objects that use the 4th byte (orientation) + ID_LIST_4TH_BYTE = [ + ID_DOOR_REGULAR, ID_DOOR_LOCKED, ID_DOOR_TRAP, ID_LAUNCHPAD, + ID_ONEWAY, ID_DRONE_CHAINGUN, ID_DRONE_LASER, ID_DRONE_ZAP, + ID_DRONE_CHASER, ID_THWUMP, ID_MICRODRONE + ] + + # Objects that use the 5th byte (mode) + ID_LIST_5TH_BYTE = [ + ID_DRONE_CHAINGUN, ID_DRONE_LASER, ID_DRONE_ZAP, ID_DRONE_CHASER, + ID_LASER_TURRET, ID_MICRODRONE, ID_LASER_TURRET + ] + + # Objects for which collisions are supported + ID_LIST_COLLIDABLE = [ + ID_MINE, ID_GOLD, ID_EXIT_SWITCH, ID_DOOR_REGULAR, + ID_DOOR_LOCKED_SWITCH, ID_DOOR_TRAP_SWITCH, ID_DRONE_ZAP, ID_DRONE_CHASER, + ID_TOGGLE_MINE, ID_MICRODRONE, ID_SHOVE_THWUMP + ] + + # Objects that admit multiple sprite states + ID_LIST_MUTABLE = [ + ID_MINE, ID_EXIT, ID_EXIT_SWITCH, ID_DOOR_REGULAR, + ID_DOOR_LOCKED, ID_DOOR_LOCKED_SWITCH, ID_DOOR_TRAP, ID_DOOR_TRAP_SWITCH, + ID_TOGGLE_MINE, ID_SHOVE_THWUMP + ] + + # Objects whose sprite movement is supported in animations + ID_LIST_MOVABLE = [ + ID_DRONE_ZAP, ID_DRONE_CHASER, ID_BOUNCEBLOCK, ID_THWUMP, ID_DEATHBALL, + ID_MICRODRONE, ID_SHOVE_THWUMP + ] + + # Objects which are moving constantly + ID_LIST_MOTIONFUL = [ + ID_DRONE_ZAP, ID_DRONE_CHASER, ID_DEATHBALL, ID_MICRODRONE + ] + + # Map properties. Challenge: Figure out what the following constant encodes ;) + BORDERS = "100FF87E1781E0FC3F03C0FC3F03C0FC3F03C078370388FC7F87C0EC1E01C1FE3F13E" + ROWS = 23 + COLUMNS = 42 + UNITS = 24 # Game units per tile + DIM = 44 # Pixels per tile at 1080p + PPC = 11 # Pixels per coordinate (1/4th tile) + PPU = DIM.to_f / UNITS # Pixels per unit + WIDTH = DIM * (COLUMNS + 2) + HEIGHT = DIM * (ROWS + 2) + + # Master palette image: 195 colors x 130 palettes (see Palette class) + PALETTE = ChunkyPNG::Image.from_file(PATH_PALETTES) + + # Map file offsets (the first 2 are only present in files, NOT in queries) + OFFSET_VERSION = 0x0 + OFFSET_SIZE = 0x4 + OFFSET_LEVEL_ID = 0x8 + OFFSET_MODE = 0xC + OFFSET_QT = 0x10 + OFFSET_AUTHOR_ID = 0x14 + OFFSET_FAVS = 0x18 + OFFSET_DATETIME = 0x1C + OFFSET_TITLE = 0x26 + OFFSET_AUTHOR = 0xA6 + OFFSET_TILES = 0xB8 + OFFSET_COUNTS = 0x47E + OFFSET_OBJECTS = 0x4CE + + # Map file sizes + SIZE_HEADER = 0xB8 # (subtract 8 in query files) + SIZE_TITLE = 128 + SIZE_AUTHOR = 16 + SIZE_TILES = ROWS * COLUMNS + SIZE_COUNTS = 2 * OBJECT_COUNT + + # N v1.4 + NV14_ROWS = 23 + NV14_COLUMNS = 31 + NV14_UNITS = 24 + NV14_USERLEVELS_FILE = "userlevels.txt" + NV14_ID_GOLD = 0 + NV14_ID_BOUNCEBLOCK = 1 + NV14_ID_LAUNCHPAD = 2 + NV14_ID_GAUSS = 3 + NV14_ID_FLOORGUARD = 4 + NV14_ID_NINJA = 5 + NV14_ID_DRONE = 6 + NV14_ID_ONEWAY = 7 + NV14_ID_THWUMP = 8 + NV14_ID_DOOR = 9 + NV14_ID_ROCKET = 10 + NV14_ID_EXIT = 11 + NV14_ID_MINE = 12 + NV14_TILEMAP = "01PONQ5432=<;:9876A@?>EDCBIHGFMLKJ".each_char.with_index.to_h + NV14_OBJECTS = { + NV14_ID_GOLD => { new_id: ID_GOLD, att: 2, name: 'gold' }, + NV14_ID_BOUNCEBLOCK => { new_id: ID_BOUNCEBLOCK, att: 2, name: 'bounceblock' }, + NV14_ID_LAUNCHPAD => { new_id: ID_LAUNCHPAD, att: 4, name: 'launchpad' }, + NV14_ID_GAUSS => { new_id: ID_GAUSS, att: 2, name: 'gauss turret' }, + NV14_ID_FLOORGUARD => { new_id: ID_FLOORGUARD, att: 3, name: 'floorguard' }, + NV14_ID_NINJA => { new_id: ID_NINJA, att: 2, name: 'ninja' }, + NV14_ID_DRONE => { new_id: ID_DRONE_ZAP, att: 6, name: 'drone' }, + NV14_ID_ONEWAY => { new_id: ID_ONEWAY, att: 3, name: 'one-way' }, + NV14_ID_THWUMP => { new_id: ID_THWUMP, att: 3, name: 'thwump' }, + NV14_ID_DOOR => { new_id: ID_DOOR_REGULAR, att: 9, name: 'door' }, + NV14_ID_ROCKET => { new_id: ID_ROCKET, att: 2, name: 'rocket' }, + NV14_ID_EXIT => { new_id: ID_EXIT, att: 4, name: 'exit' }, + NV14_ID_MINE => { new_id: ID_MINE, att: 2, name: 'mine' } + } + NV14_DRONE_TYPE_ZAP = 0 + NV14_DRONE_TYPE_LASER = 1 + NV14_DRONE_TYPE_CHAINGUN = 2 + + # Change the colors of the last row in the palette image, intended to temporarily + # store a custom palette. + def self.change_custom_palette(json) + palette = JSON.parse(json) + return false if Palette::FILES.keys.sort != palette.keys.sort + return false if palette.any?{ |k, v| !v.is_a?(Array) || v.size != Palette::FILES[k][:size] } + palette.sort_by{ |k, v| Palette::FILES[k][:position] }.map(&:last).flatten.each_with_index{ |color, i| + PALETTE[i, PALETTE.height - 1] = ChunkyPNG::Color.from_hex(color) + } + true + rescue => e + lex(e, 'Failed to parse palette payload') + false + end + + # TODO: Perhaps store object data without transposing, hence being able to skip + # the decoding when dumping + # TODO: Or better yet, store the entire map data in a single field, Zlibbed, for + # instantaneous dumps + def self.encode_tiles(data) + Zlib::Deflate.deflate(data.map{ |a| a.pack('C*') }.join, 9) + end + + def self.encode_objects(data) + Zlib::Deflate.deflate(data.transpose.map{ |a| a.pack('C*') }.join, 9) + end + + def self.decode_tiles(data) + Zlib::Inflate.inflate(data).bytes.each_slice(42).to_a + end + + def self.decode_objects(data) + dec = Zlib::Inflate.inflate(data) + dec.bytes.each_slice((dec.size / 5).round).to_a.transpose + end + + # Parse a level in Metanet format + # This format is only used internally by the game for the campaign levels, + # and differs from the standard userlevel format + def self.parse_metanet_map(data, index = nil, file = nil, pack = nil) + name = '' + name += " #{index}" if !index.nil? + name += " from #{file}" if !file.nil? + name += " for #{pack}" if !pack.nil? + error = "Failed to parse map#{name}" + warning = "Abnormality found parsing map#{name}" + + # Ensure format is "$map_name#map_data#", with map data being hex chars + if data !~ /^\$(.*)\#(\h+)\#$/ + err("#{error}: Incorrect overall format.") + return + end + title, map_data = $1, $2 + size = map_data.length + + # Map data is dumped binary, so length must be even, and long enough to hold + # header and tile data + if size % 2 == 1 || size / 2 < 4 + 23 * 42 + 2 * 26 + 4 + err("#{error}: Incorrect map data length (odd length, or too short).") + return + end + + # Map header missing + if !map_data[0...8] == '00000000' + err("#{error}: Header missing.") + return + end + + # Parse tiles. Warning if invalid ones are found + tiles = [map_data[8...1940]].pack('h*').bytes + invalid_count = tiles.count{ |t| t > 33 } + alert("#{warning}: #{invalid_count} invalid tiles.") if invalid_count > 0 + tiles = tiles.each_slice(42).to_a + + # Parse objects + offset = 1940 + objects = [] + gold = 0 + OBJECTS.reject{ |id, o| o[:old] == -1 }.sort_by{ |id, o| o[:old] }.each{ |id, type| + # Parse object count + if size < offset + 4 + err("#{error}: Object count for ID #{id} not found.") + return + end + count = map_data[offset...offset + 4].scan(/../m).map(&:reverse).join.to_i(16) + gold = count if id == 2 + + # Parse entities of this type + if size < offset + 4 + 2 * count * type[:att] + err("#{error}: Object data incomplete for ID #{id}.") + return + end + map_data[offset + 4...offset + 4 + 2 * count * type[:att]].scan(/.{#{2 * type[:att]}}/m).each{ |o| + atts = [o].pack('h*').bytes + if ![3, 6, 8].include?(id) + objects << [id] + atts.ljust(4, 0) + else # Doors need special handling + objects << [id] + atts[0..-3].ljust(4, 0) # Door + objects << [id + 1] + atts[-2..-1].ljust(4, 0) # Door switch + end + } + offset += 4 + 2 * count * type[:att] + } + + # Sort objects by ID, but: + # 1) In a stable way, i.e., maintaining the order of tied elements + # 2) The pairs 6/7 and 8/9 are not sorted, but maintained staggered + # Both are important to respect N++'s data format + objects = objects.stable_sort_by{ |o| o[0] == 7 ? 6 : o[0] == 9 ? 8 : o[0] } + + # Warnings if footer is incorrect + if size != offset + 8 + alert("#{warning}: Incorrect footer length.") + elsif map_data[offset..-1] != '00000000' + alert("#{warning}: Incorrect footer format.") + end + + # Return map elements + { title: title, tiles: tiles, objects: objects, gold: gold } + end + + # Parse a text file containing maps in Metanet format, one per line + # This is the format used by the game to store the main campaign of levels + def self.parse_metanet_file(file, limit, pack, data: nil) + fn = File.basename(file) + if !data && !File.file?(file) + err("File '#{fn}' not found parsing Metanet file") + return + end + + maps = (data || File.binread(file)).split("\n").take(limit) + count = maps.count + maps = maps.each_with_index.map{ |m, i| + dbg("Parsing map #{"%-3d" % (i + 1)} / #{count} from #{fn} for #{pack}...", progress: true) + parse_metanet_map(m.strip, i, fn, pack) + } + Log.clear + maps + rescue => e + lex(e, "Error parsing Metanet map file #{fn} for #{pack}") + nil + end + + # Parse a raw N v1.4 map. It's a robust implementation that never fails no matter + # how corrupt the map data is, instead making the necessary changes and warnings. + # Any of these warnings should be carefully considered by the user, because it + # indicates anything in the range from unsupported things to corrupt map data. + # + # Possible changes made by this function: + # - Tile data: + # * If it's shorter than necessary, it'll be padded. + # * If it's longer than necessary, it'll be truncated. + # * Unrecognized tiles are swapped by an empty tile. + # - Object data: + # * Malformed objects (with an incorrect format) are skipped. + # * Unrecognized objects (with an incorrect ID) are skipped. + # * Objects with malformed parameters are skipped. + # * Z-snapped objects are rounded to the nearest coordinate. + # * Out of bounds objects are kept (but warned), unless they exceed the available + # range (0-255), in which case they are skipped too. + # - Object parameters: + # * Objects with fewer parameters than required are skipped. + # * Invalid launchpad powers (e.g. teleporters) are normalized and rounded. + # * Invalid drones are skipped. Invalid pathings are defaulted to Dumb CCW (3). + # * Invalid one-way directions (glitch) are defaulted to Down (1). + # * Invalid thwump directions (glitch) are defaulted to Right (0). + # * Invalid door orientations (glitch) are defaulted to Vertical (0). + # * Non-grid-aligned doors don't show (moved to 0,0), the switches do show. + # + # Notes: + # * This function assumes the input data is already rid of any whitespace. + # + # TODO: + # * Handle corrupt parameters properly (e.g. using to_f to begin with eliminates NaN's). + def self.parse_nv14_map(tile_data, object_data, warnings) + xoffset = 6 # Amount of empty columns to add on the left (center map) + + # Parse tile data + tile_count = tile_data.size + canvas_size = NV14_ROWS * NV14_COLUMNS + if tile_count < canvas_size + warnings["Padded tile data with #{canvas_size - tile_count} extra tiles."] + tile_data = tile_data.ljust(canvas_size, '0') + elsif tile_count > canvas_size + warnings["Truncated tile data by #{tile_count - canvas_size} tiles."] + tile_data = tile_data[0, canvas_size] + end + tiles = Array.new(ROWS){ Array.new(COLUMNS, 1) } + tile_data.each_char.with_index{ |c, i| + x = i / ROWS + xoffset + y = i % ROWS + t = NV14_TILEMAP[c] + tiles[y][x] = t || 0 + warnings[:unknown_tile] << "Tile %c at (%d, %d)" % [c, x, y] if !t + } + + # Parse object data + objects = [] + object_data.split('!').each{ |object| + # General integrity checks and ID + next warnings[:malformed_obj] << object if object !~ /^(\d+)\s*\^([\d\.\-,\s]+)$/ + old_id, params = $1.to_i, $2.split(',').map(&:to_f) + next warnings[:unknown_obj] << "ID #{old_id} (#{object}), should be 0 - 12" if !old_id.between?(0, 12) + obj = NV14_OBJECTS[old_id] + id = obj[:new_id] + + # Coordinates (round Z-snapped coordinates and skip heavily out of bounds objects) + next warnings[:bad_params] << "%s %s should have at least 2 parameters (x, y)" % [obj[:name].capitalize, object] if params.count < 2 + x, y, xf, yf, zsnap, oob, skip = nv14_coord(*params[0, 2], xoffset, 0) + name = "%s at (%g, %g)" % [obj[:name].capitalize, (x / 4.0 - 1).round(2), (y / 4.0 - 1).round(2)] + next warnings[:oob_skip] << name if skip + warnings[:zsnap] << "%s at (%.3f, %.3f) rounded to (%g, %g)" % [obj[:name].capitalize, xf / 4.0 - 1, yf / 4.0 - 1, (x / 4.0 - 1).round(2), (y / 4.0 - 1).round(2)] if zsnap + warnings[:oob] << name if oob + + # Specific params for each object type + next warnings[:bad_params] << "%s should have %d parameters" % [name, obj[:att]] if params.count != obj[:att] + o, m = 0, 0 + switch = nil + case old_id + when NV14_ID_LAUNCHPAD + # In v1.4, launchpad power was present in the map data, which could be + # used to make e.g. teleporters in arbitrary directions. + # Here we round them to the closest direction. + vx, vy = params[2, 2] + power = vx * vx + vy * vy + dir = vec2dir(vx, vy) + warnings[:launchpad] << "%s has power %.2f" % [name, power] if !num_eql?(power, 1) || !is_int(dir) + o = dir2or(dir) + when NV14_ID_DRONE + # Parse params, we handle invalid ones gracefully + path, seeking, type, direction = params[2, 4].map(&:round) + seeking = seeking != 0 + + # Type + case type + when NV14_DRONE_TYPE_ZAP + id = seeking ? ID_DRONE_CHASER : ID_DRONE_ZAP + name = (seeking ? 'Chaser ' : 'Zap ') + name.downcase + when NV14_DRONE_TYPE_LASER + id = ID_DRONE_LASER + name = 'Laser ' + name.downcase + when NV14_DRONE_TYPE_CHAINGUN + id = ID_DRONE_CHAINGUN + name = 'Chaingun ' + name.downcase + else + next warnings[:drone_invalid] << "%s is type %d (should be 0 - 2)" % [name, type] + end + + # Orientation (RDLU, mod 4 is taken in v1.4) + o = direction % 4 * 2 + + # Pathing. + # v1.4 has 2 pathings unsupported in N++ (alternate and random). + # Also, for invalid pathings: + # v1.4 defaults to an immobile drone. + # N++ defaults to Dumb CCW (3). + if !path.between?(0, 3) + adj = path.between?(4, 5) ? 'unsupported' : 'invalid' + err = case path + when 4 + 'alternate' + when 5 + 'quasi-random' + else + 'unknown' + end + warnings[:drone_path] << "%s has %s pathing %d (%s)" % [name, adj, path, err] + path = 3 + end + m = path + when NV14_ID_ONEWAY + # Invalid one-way directions in v1.4 result in a glitch one-way, + # facing up but with collision facing down (1) + direction = params[2] + if !is_int(direction) || !direction.between?(0, 3) + warnings[:oneway] << "%s has direction %g (should be 0 - 3)" % [name, direction] + direction = 1 + end + o = 2 * direction.round + + # In v1.4, the position indicates the center of the tile that contains + # the one-way, whereas in N++ it indicates the center of the actual + # one-way, so we adjust half a tile in the direction the one-way faces. + vec = or2vec(o) + x += 2 * vec[0] + y += 2 * vec[1] + when NV14_ID_THWUMP + # Invalid thwump directions in v1.4 result in a glitch static thwump, + # facing down but with a deadly right side (0) + direction = params[2] + if !is_int(direction) || !direction.between?(0, 3) + warnings[:thwump] << "%s has direction %g (should be 0 - 3)" % [name, direction] + direction = 0 + end + o = 2 * direction.round + when NV14_ID_DOOR + # Parse params + dir, trap, cx, cy, locked, tx, ty = params[2, 7] + trap = trap != 0 + locked = locked != 0 + + # Door type and switch. Locked boolean takes precedence over trap boolean + if locked + id = ID_DOOR_LOCKED + switch = [ID_DOOR_LOCKED_SWITCH, x, y, 0, 0] + name = 'Locked ' + name.downcase + elsif trap + id = ID_DOOR_TRAP + switch = [ID_DOOR_TRAP_SWITCH, x, y, 0, 0] + name = 'Trap ' + name.downcase + else + id = ID_DOOR_REGULAR + name = 'Regular ' + name.downcase + end + + # Invalid door directions in v1.4 result in a glitch door, with vertical + # graphics but with very buggy collision + if dir != 0 && dir != 1 + warnings[:door_dir] << "%s has direction %g (should be 0 or 1)" % [name, dir] + dir = 0 + end + o = dir.round * 2 + + # Compute door position. It is anchored at: + # v1.4 - The bottom-right corner of the cell that contains the door. + # N++ - The center of the door, like any other object. + # In v1.4 we have the following elements: + # x/y: Coordinates of the switch + # cx/cy: Index of the grid cell, whose bottom-right corner is the anchor point of the door + # tx/ty: Translation that needs to be applied to the index + # (0, 0) for R and D, (-1, 0) for L, and (0, -1) for U + # Non-grid-aligned doors disappear (but their switch remains) + if !is_int(cx) || !is_int(cy) || !is_int(tx) || !is_int(ty) + cx, cy, tx, ty = 0, 0, 0, 0 + warnings[:door_pos] << name + end + vx, vy = lnorm(*or2vec(o)).map(&:abs) # Direction vector of door + ax, ay = (xoffset + cx + tx + 1).round, (cy + ty + 1).round + x, y = (4 * (ax - 0.5 * vx)).round, (4 * (ay - 0.5 * vy)).round + when NV14_ID_EXIT + # Switch position (again, round Z-snap and skip if fully OOB) + sx, sy, sxf, syf, zsnap, oob, skip = nv14_coord(*params[2, 2], xoffset, 0) + name = "Exit switch at (%g, %g)" % [(sx / 4.0 - 1).round(2), (sy / 4.0 - 1).round(2)] + next warnings[:oob_skip] << name if skip + warnings[:zsnap] << "Exit switch at (%.3f, %.3f) rounded to (%g, %g)" % [sxf / 4.0 - 1, syf / 4.0 - 1, (sx / 4.0 - 1).round(2), (sy / 4.0 - 1).round(2)] if zsnap + warnings[:oob] << name if oob + switch = [ID_EXIT_SWITCH, sx, sy, 0, 0] + end + + objects << [id, x, y, o, m] + objects << switch if switch + } + + # Sort objects by ID, preserving ties and excluding locked/trap switches + objects = objects.stable_sort_by{ |o| o[0] == 7 ? 6 : o[0] == 9 ? 8 : o[0] } + [tiles, objects] + end + + # Parse an N v1.4 userlevels file (or data buffer). Two formats are tried: + # - First, the standard $title#author#comments#tiles|objects# format + # - If no results, look for map data only, but this should ideally be avoided. + def self.parse_nv14_file(filename: nil, content: nil, warnings: nil) + # Read file and skip till map data begins + if !content + return if !File.file?(filename) + content = File.read(filename) + end + start = content.rindex('&userdata=') + + # Parse each map + maps = content[start .. -1].scan(NV14_USERLEVEL_PATTERN) + count = maps.count + maps = content[start .. -1].scan(NV14_MAP_PATTERN).map{ |tile_data, object_data| + ['', '', '', tile_data + '|' + object_data] + } if count == 0 + maps = maps.each_with_index.map{ |map_data, i| + dbg("Parsing N v1.4 map #{"%-3d" % (i + 1)} / #{count}...", progress: true) + lvl_warnings = Hash.new{ |hash, key| hash[key] = [] } + + # Parse metadata + title, author, comments, data = *map_data + tile_data, object_data, mod_data = data.split('|').map{ |str| str.gsub(/\s+/m, '') } + if !tile_data || tile_data.empty? + tile_data = '0' * (NV14_ROWS * NV14_COLUMNS) + lvl_warnings[:missing_tiles] + else + lvl_warnings[:missing_objects] if !object_data || object_data.empty? + end + lvl_warnings[:nreality] if !!mod_data + + # Parse map data + tiles, objects = parse_nv14_map(tile_data.to_s, object_data.to_s, lvl_warnings) + + # Format warnings + if warnings && !lvl_warnings.empty? + lvl_warnings.keys.each{ |key| + c = lvl_warnings[key].size + new_key = case key + when :missing_tiles then "Map data missing. Is there a rogue line break?" + when :missing_objects then "Object data missing. Is it a tileset?" + when :nreality then "NReality data found (ignoring)." + when :unknown_tile then "Skipped #{c} unrecognized tiles:" + when :malformed_obj then "Skipped #{c} malformed objects:" + when :unknown_obj then "Skipped #{c} unrecognized objects:" + when :bad_params then "Skipped #{c} objects with bad parameters:" + when :zsnap then "Found #{c} Z-snapped objects:" + when :oob then "Found #{c} out of bounds objects:" + when :oob_skip then "Skipped #{c} out of bounds objects:" + when :launchpad then "Normalized #{c} edited launchpads:" + when :drone_invalid then "Skipped #{c} invalid drones:" + when :drone_path then "Found #{c} invalid drone pathings (defaulted to dumb CCW):" + when :oneway then "Found #{c} glitch one-way orientations (defaulted to down):" + when :thwump then "Found #{c} glitch thwump orientations (defaulted to right):" + when :door_dir then "Found #{c} glitch door orientations (defaulted to vertical):" + when :door_pos then "Found #{c} non-grid-aligned doors which won't show:" + else key.to_s + end + lvl_warnings[new_key] = lvl_warnings.delete(key) + } + warnings["=== Map #{i} #{title}"] = lvl_warnings + end + + { title: title, author: author, comments: comments, tiles: tiles, objects: objects } + } + Log.clear + maps + rescue => e + lex(e, "Error parsing N v1.4 userlevels file") + nil + end + + # Convert an N v1.4 userlevels file to N++ map files, zipping them if there's more than one + def self.convert_nv14_file(filename: nil, content: nil, warnings: nil, prefix: false, burn_author: false, burn_comments: false) + # Parse file contents for N v1.4 maps + bench(:start) + levels = parse_nv14_file(filename: filename, content: content, warnings: warnings) + bench(:step, "Parsing") + return if !levels + return { count: 0 } if levels.empty? + + # Edit level names: + # - Optionally append author name or comments. + # - Append index when a level name is duplicated (needed since they're also the filename) + # - Optionally prepend global index, to keep track of the original order + # - Truncate to 128 characters + count = levels.size + prefix_len = prefix ? (count - 1).to_s.length + 1 : 0 + freq = Hash.new(0) + levels.each_with_index{ |lvl, i| + lvl[:title] = lvl[:title].strip[0, SIZE_TITLE - prefix_len - 5] # Truncate just level name, for keying the hash + lvl[:title] = "Untitled" if lvl[:title].empty? + sanitized = sanitize_filename(lvl[:title]) + lvl[:title] << ' (%d)' % freq[sanitized] if (freq[sanitized] += 1) > 1 + lvl[:title] << ' // ' << lvl[:author].strip if burn_author + lvl[:title] << ' // ' << lvl[:comments].strip if burn_comments + lvl[:title].prepend('%0*d ' % [prefix_len - 1, i]) if prefix + lvl[:title].slice!(SIZE_TITLE..) # Truncate again, so the full thing is within bounds + } + + # Dump maps to binary files following N++ format + levels = levels.map{ |lvl| + [ + sanitize_filename(lvl[:title]), + dump_level(lvl[:tiles], lvl[:objects], title: lvl[:title]) + ] + }.to_h + bench(:step, "Dumping") + + # Return hash + return { count: 1, name: levels.first[0], file: levels.first[1] } if count == 1 + { + count: count, + name: sanitize_filename(filename).sub(/\..{,3}$/, '') + '.zip', + file: zip(levels) + } + end + + #----------------------------------------------------------------------------# + # MAP FILE DOCUMENTATION | + #----------------------------------------------------------------------------# + # 4B - Version (only in files, not queries) | + # 4B - File size (only in files, not queries) | + # 4B - Level ID (-1, not set) | + # 4B - Mode (0 = solo, 1 = coop, 2 = race, 3 = hc) | + # 4B - Query type (37, not set, see QT_ enum) | + # 4B - Author ID (-1, not set, except in queries) | + # 4B - Fav count (0, not set) | + # 10B - System time (0, not set) | + # 128B - Title (ASCII only, padded) | + # 16B - Author name (0, not set) | + # 2B - Padding (0) | + # 966B - Tile data (42 * 23 matrix) | + # 80B - Object counts (40 shorts) | + # Rest - Object data (5-tuples) | + #----------------------------------------------------------------------------# + # \\\ Dumping map data into N++ binary userlevel format /// | + #----------------------------------------------------------------------------# + # Object counts are automatically computed if not stated. But having the | + # the parameter is useful for object count hacks, as well as hashing (which | + # entails modifying the object data in a certain way, complete_object_data). | + # Also, note that the counts for locked / trap door switches are set to 0, | + # which is also what the game does, except when hashing (counts should be | + # provided in that case anyway). | + # Data can be dumped in query mode, which is the format used in userlevel | + # provided. It differs mainly in that it lacks an 8 byte mini header that | + # individual map files do contain. | + # Most keyword arguments relative to the map data can normally be ignored: | + # - Recommendable ones are mode and title. | + # - In query mode, the author ID is set as well. | + # - All the others are normally unset to the default values in the game. | + # For hashing, only set the mode and title, and no query mode. | + #----------------------------------------------------------------------------# + + def self.dump_level( + tiles, # Matrix of tiles + objects, # List of objects + counts = nil, # Optional list of object counts + output: nil, # Optional IO object to pipe binary output to (using <<), otherwise a new string is used + query: false, # Use format for userlevel queries (minor differences) + magic: 0, # Magic number at the start of the file (not in query mode) + title: '', # Title of the map, ASCII only padded to 128 chars + author: '', # Author name, ASCII only padded to 16 chars, normally unset + author_id: -1, # Author ID, normally unset to -1 except in query mode + level_id: -1, # Level ID, normally unset to -1 + mode: 0, # Playing mode (0 solo, 1 coop, 2 race) + qt: QT_UNSET, # Query type, normally unset to 37 + favs: 0, # Favourite count, normally unset to 0 + time: '' # Time of creation (10 bytes), normally unset to 0 (it's 5 shorts: year, month, day, hour, minute) + ) + output = "".b unless output + + # Miniheader only present in userlevel files, but not in queries + if !query + size = SIZE_HEADER + ROWS * COLUMNS + OBJECT_COUNT * 2 + 5 * objects.size + output << [magic, size].pack('l<2') + end + + # Regular header, padded at the end (it's 8 bytes aligned) + output << [level_id, mode, qt, author_id, favs].pack('l<5') + output << [time, to_ascii(title), to_ascii(author), 0].pack('a10a128a16s') + + # Map data + output << tiles.map{ |a| a.pack('C*') }.join + if !counts + counts = object_counts(objects) + counts[ID_DOOR_LOCKED_SWITCH] = 0 + counts[ID_DOOR_TRAP_SWITCH] = 0 + end + output << counts.pack("S<#{OBJECT_COUNT}") + output << objects.map{ |o| o.pack('C5') }.join + end + + def self.object_counts(objects) + object_counts = [0] * OBJECT_COUNT + objects.each{ |o| object_counts[o[0]] += 1 if o[0] < OBJECT_COUNT } + object_counts + end + + # Return tiles as a matrix of integer + def tiles(version: nil) + Map.decode_tiles(tile_data(version: version)) + end + + # Return objects as an array of 5-tuples of ints + def objects(version: nil) + Map.decode_objects(object_data(version: version)) + end + + # Apply a few fixes to object data + def normalized_objects + objects.map{ |o| + o[3] = 0 if !ID_LIST_4TH_BYTE.include?(o[0]) + o[4] = 0 if !ID_LIST_5TH_BYTE.include?(o[0]) + o + }.stable_sort_by{ |o| o[0] == 7 ? 6 : o[0] == 9 ? 8 : o[0] } + end + + # Return object counts + def object_counts(version: nil) + Map.object_counts(objects(version: version)) + end + + # Shortcuts for some object counts + def gold(version: nil) + object_counts(version: version)[ID_GOLD] + end + + # Measure complexity of a map with regards to animations by the amount of + # moving entities is has. + def complexity + object_counts.values_at(0, *ID_LIST_MOTIONFUL).sum + end + + # This is used for computing the hash of a level. It's required due to a + # misimplementation in N++, which instead of just hashing the map data, + # overflows and copies object data from the next levels before doing so. + # Returns false if we ran out of objects, or true if we completed the data + # successfully. Userlevels aren't completed (their hashes aren't checked + # by the server anyways). + # Params: + # - list: Current list of objects + # - n: Count of remaining objects needed to complete data + def complete_object_data(list, n) + return true if n == 0 || is_userlevel? + successor = next_h(tab: false) + return false if successor == self + objs = successor.objects.take(n) + list.push(*objs) + successor.complete_object_data(list, n - objs.count) + end + + # Generate a file with the usual userlevel format + # - query: The format for userlevel query files is used (shorter header) + # - hash: Recursively fetches object data from next level to compute hash later + # - version: Version of the map (for mappacks we may hold multiple edits) + def dump_level(query: false, hash: false, version: nil) + # Header params (the rest are left unset) + mode = is_mappack? ? self.mode : Userlevel.modes[self.mode] + title = is_mappack? ? self.longname : self.title + author_id = is_userlevel? && query ? self.author_id : -1 + + # Map data (counts are manually computed when hashing, as obj data may change) + tiles = self.tiles(version: version) + objects = self.objects(version: version) + counts = nil + if hash + counts = Map.object_counts(objects) + door_count = counts[ID_DOOR_LOCKED] + counts[ID_DOOR_TRAP] + return nil unless complete_object_data(objects, door_count) + end + + # Dump raw binary data + Map.dump_level(tiles, objects, counts, query: query, mode: mode, title: title, author_id: author_id) + end + + # Computes the level's hash, which the game uses for integrity verifications + # c - Use the C SHA1 implementation (vs. the default Ruby one) + # v - Map version to hash + # pre - Serve precomputed hash stored in MappackHash table + def _hash(c: false, v: nil, pre: false) + if pre && c && !is_userlevel? + stored = saved_hash(v: v) + return stored if stored + end + map_data = dump_level(hash: true, version: v) + return nil if map_data.nil? + sha1(PWD + map_data[0xB8..-1], c: c) + rescue + nil + end + + # Compute the "distance" between two maps as a way to measure how different they are + def distance(map, tiles: true, objects: true) + distance = 0 + distance += DamerauLevenshtein.distance(self.tiles.flatten.join, map.tiles.flatten.join, 1, ROWS * COLUMNS) if tiles + distance += (normalized_objects ^ map.normalized_objects).size if objects + distance + end + + # <--------------------------------------------------------------------------> + # SCREENSHOT GENERATOR + # <--------------------------------------------------------------------------> + + # Change color 'before' to color 'after' in 'image'. + # The normal version uses tolerance to change close enough colors, alpha blending... + # The fast version doesn't do any of this, but is 10x faster + def self.mask(image, before, after, bg: ChunkyPNG::Color::WHITE, tolerance: 0.5, fast: false) + if fast + image.pixels.map!{ |p| p == before ? after : 0 } + return image + end + + new_image = ChunkyPNG::Image.new(image.width, image.height, ChunkyPNG::Color::TRANSPARENT) + image.width.times{ |x| + image.height.times{ |y| + distance = ChunkyPNG::Color.euclidean_distance_rgba(image[x, y], before) + score = distance.to_f / ChunkyPNG::Color::MAX_EUCLIDEAN_DISTANCE_RGBA + new_image[x, y] = ChunkyPNG::Color.compose(after, bg) if score < tolerance + } + } + new_image + end + + # Generate the sprite of a tile / object, by paiting and compositing each layer + # in the desired palette. Since we can't rotate by 45º, we have special diagonal + # copies of the sprites. We also have different versions for each possible + # state (e.g. toggled vs untoggled mine). + def self.generate_object(entity_id, palette_id, is_object = true, diag = false, state = 0) + # Select necessary layers + path = is_object ? PATH_OBJECTS : PATH_TILES + parts = Dir.entries(path).select{ |file| + match_id = file[0..1] == '%02X' % entity_id # Filter sprites by ID + next match_id if !is_object + match_diag = file[2] == (diag ? 'x': '-') # Filter by orientation + match_state = file[3] == state.to_s # Filter by entity state + match_id && match_diag && match_state + }.sort + + # Paint and combine the layers + masks = parts.map{ |part| + [part[-5].to_i, ChunkyPNG::Image.from_file(File.join(path, part))] + }.to_h + images = masks.map{ |color, image| + mask(image, ChunkyPNG::Color::BLACK, PALETTE[(is_object ? OBJECTS[entity_id][:pal] : 0) + color, palette_id], fast: true) + } + dims = [ images.map(&:width).max || 1, images.map(&:height).max || 1] + output = ChunkyPNG::Image.new(*dims, ChunkyPNG::Color::TRANSPARENT) + images.each{ |image| output.compose!(image, 0, 0) } + output + rescue => e + lex(e, "Failed to generate sprite for #{is_object ? 'object' : 'tile'} #{entity_id}.") + ChunkyPNG::Image.new(1, 1, ChunkyPNG::Color::TRANSPARENT) + end + + # Given a bounding box (rectangle) in game units, return the range of grid + # cells intersected by it. The rectangle is given in the form [X, Y, W, H], + # where [X, Y] are the coordinates of the upper left corner (not the center), + # and [W, H] are the width and height, respectively. + # + # The neighbouring cells may also be optionally included. + # If the bbox only has 2 components (X and Y), then a single point is checked. + def self.gather_cells(bbox, neighbours = false) + bbox << 0 << 0 if bbox.size == 2 + pad = neighbours ? 1 : 0 + x_min = [(bbox[0] / UNITS).floor - pad, 0].max + y_min = [(bbox[1] / UNITS).floor - pad, 0].max + x_max = [((bbox[0] + bbox[2]) / UNITS).floor + pad, COLUMNS + 1].min + y_max = [((bbox[1] + bbox[3]) / UNITS).floor + pad, ROWS + 1].min + [x_min, y_min, x_max, y_max] + end + + # Gather a list of all the objects that intersect a given rectangle and its + # neighbouring cells. The list is sorted by drawing preference. + # Notes: + # - We are ignoring out of bounds objects (frame is included though). + def self.gather_objects(objects, bbox) + x_min, y_min, x_max, y_max = gather_cells(bbox, true) + objs = [] + (x_min .. x_max).each{ |x| + (y_min .. y_max).each{ |y| + objs.push(*objects[x][y]) + } + } + objs + end + + # Transform bbox from game coordinates to pixel coordinates. + def self.bbox2pixel(bbox, ppc) + px = bbox.map{ |c| c.to_f * PPU * ppc / PPC } + [px[0].round, px[1].round, px[2].ceil, px[3].ceil] + end + + # Transform bbox from pixel coordinates to game coordinates. + def self.bbox2game(bbox, ppc) + bbox.map{ |c| c.to_f * PPC / ppc / PPU } + end + + # Determine the scale of the screenshot based on the highscoreable type and + # whether it's an animation or not + def self.find_scale(h, anim) + return ANIMATION_SCALE if anim + return IMAGESEARCH_SCALE if h.is_a?(Array) + + case h + when Episodish + SCREENSHOT_SCALE_EPISODE + when Storyish + SCREENSHOT_SCALE_STORY + else + SCREENSHOT_SCALE_LEVEL + end + end + + # TODO: It seems fitting to create an Object/Entity class that handles all the + # basic collision and drawing routines below. + + # Change an object's position in the map data to a new one given in game units + def self.move_object(grid, o, x, y) + # Only move if enough distance has been traveled (reduce CPU stress) + distance = (x - UNITS * o[1] / 4.0).abs + (y - UNITS * o[2] / 4.0).abs + return false if distance < ANIM_MOVE_THRESHOLD + + # New parameters + new_x = 4.0 * x / UNITS + new_y = 4.0 * y / UNITS + new_cell_x = (new_x / 4.0).floor + new_cell_y = (new_y / 4.0).floor + new_list = grid[new_cell_x]&.[](new_cell_y) + + # Update cell + if !new_list || !new_list.include?(o) + cell_x = (o[1] / 4.0).floor + cell_y = (o[2] / 4.0).floor + list = grid[cell_x]&.[](cell_y) + list.delete(o) if list + new_list.append(o) if new_list + end + + # Update coordinates + o[1] = new_x + o[2] = new_y + true + end + + # Change an object's orientation in the map data + def self.turn_object(o, angle) + o[3] = angle + end + + # Changes an object's state + def self.mutate_object(o, state) + o[4] = state + end + + # Disables an object so that it can no longer be collided with or be drawn + def self.disable_object(o) + mutate_object(o, -1) + end + + # Resets an object to its initial state (in particular, this will re-enable it) + def self.reset_object(o) + mutate_object(o, 0) + end + + # Checks if an object is enabled + def self.check_object(o) + o[4] != -1 + end + + # Perform entity movements and logical collision effects in a given frame range. + # This is done by editing the map data accordingly. Returns the list of regions + # (bounding boxes) that need to be redrawn. + # TODO: For optimization purposes, try to remove as much redundancy as possible + # (e.g. joining overlapping boxes into one). + def self.think(object_dict, object_grid, nsim, f, step, gif) + bboxes = [] + saved_bboxes = OBJECT_COUNT.times.map{ |id| [id, []] }.to_h + + # For the last frame in the range, move entities to new position + nsim.movements(f, step, ppc: 0).each{ |mov| + # Filter movements + next unless ID_LIST_MOVABLE.include?(mov[:id]) # Movable object + next unless o = object_dict[mov[:id]]&.[](mov[:index]) # Object found + next unless check_object(o) # Object not removed + + # Move object + old_bbox = find_object_bbox(o, gif[:object_atlas], gif[:ppc]) + moved = move_object(object_grid, o, *mov[:coords]) + next if !moved + saved_bboxes[mov[:id]] << mov[:index] + + # Figure out which area must be redrawn (trying to be efficient) + new_bbox = find_object_bbox(o, gif[:object_atlas], gif[:ppc]) + full_bbox = bbox_hull([old_bbox, new_bbox], round: true) + if bbox_area(full_bbox) <= bbox_area(old_bbox) + bbox_area(new_bbox) + bboxes << full_bbox + else + bboxes << old_bbox << new_bbox + end + } + + # For every frame in the range, fetch all collisions by all ninjas + (0 ... step).each{ |s| + nsim.collisions(f + s).each{ |col| + # Filter collisions + next unless ID_LIST_COLLIDABLE.include?(col.id) # Collidable object + next unless o = object_dict[col.id]&.[](col.index) # Object found + next unless check_object(o) # Object not removed + + # Mark region to be redrawn + if !saved_bboxes[col.id].include?(col.index) + bboxes << find_object_bbox(o, gif[:object_atlas], gif[:ppc]) + end + + # Update object's state + if ID_LIST_MUTABLE.include?(col.id) + case col.id + when ID_SHOVE_THWUMP + new_state = [col.state / 4, 2].min + else + new_state = col.state + end + mutate_object(o, new_state) + end + + # Additional collision effects + case col.id + when ID_GOLD + # Remove collected gold + disable_object(o) + when ID_EXIT_SWITCH, ID_DOOR_LOCKED_SWITCH, ID_DOOR_TRAP_SWITCH + # For switches, toggle / remove door too + door = object_dict[o[0] - 1]&.[](o[5]) + next alert("Door for collided switch not found.") if !door + door[4] = door[0] == ID_DOOR_LOCKED ? -1 : o[4] + bboxes << find_object_bbox(door, gif[:object_atlas], gif[:ppc]) + when ID_DRONE_ZAP, ID_DRONE_CHASER, ID_MICRODRONE, ID_SHOVE_THWUMP + # Rotate drones and shwumps + dir = col.id == ID_SHOVE_THWUMP ? col.state % 4 : col.state + turn_object(o, 2 * dir) + end + } + } + + bboxes.compact.uniq + end + + # Parse map(s) data, sanitize it, and return objects and tiles conveniently + # organized for screenshot generation. + def self.parse_maps(maps, v = 1, anim = true, trace = false) + # Read objects, remove glitch ones + objects = maps.map{ |map| + map.map.objects(version: v).reject{ |o| o[0] > 28 } + } + + # Perform convenience modifications and sanity checks + objects.each{ |map| + counts = [0] * OBJECT_COUNT + map.each{ |o| + # Remove glitched orientations and non-zero orientations for still objects + o[3] = 0 if o[3] > 7 || ID_LIST_FIXED.include?(o[0]) + + # Use 5th field to store the "state" + o[4] = 0 + + # Change initial state of trap doors and toggle mines to "untoggled" + o[4] = 1 if [8, 9, 21].include?(o[0]) + + # Don't include ninja for animations + o[4] = -1 if o[0] == 0 && anim && !trace + + # Add 6th field containing the entity index + o << counts[o[0]] + counts[o[0]] += 1 + } + } + + # Build an object dictionary keyed on row and column, for fast access, akin + # to GridEntity + object_grid = maps.map{ + (COLUMNS + 2).times.map{ |t| + [t, (ROWS + 2).times.map{ |s| [s, []] }.to_h] + }.to_h + } + + objects.each_with_index{ |map, i| + map.each{ |o| + x = o[1] / 4 + y = o[2] / 4 + next if x < 0 || x > COLUMNS + 1 || y < 0 || y > ROWS + 1 + object_grid[i][x][y] << o + } + } + + # Build another object dictionary, this time keyed by id and index + object_dict = maps.map{ OBJECT_COUNT.times.map{ |i| [i, {}] }.to_h } + objects.each_with_index{ |map, i| + map.each{ |o| + object_dict[i][o[0]][o[5]] = o + } + } + + # Parse tiles, add frame, and reject glitch tiles + tiles = maps.map{ |map| + tile_list = map.map.tiles(version: v).map(&:dup) + tile_list.each{ |row| row.unshift(1).push(1) } # Add vertical frame + tile_list.unshift([1] * (COLUMNS + 2)).push([1] * (COLUMNS + 2)) # Add horizontal frame + tile_list.each{ |row| row.map!{ |t| t > 33 ? 0 : t } } # Reject glitch tiles + tile_list + } + + [tiles, object_grid, object_dict] + end + + # Parse all elements we'll need to screenshot and trace / animate the routes + def self.parse_trace(nsim, texts, h, ppc: PPC, v: nil, anim: true, trace: false) + # Filter parameters + n = [nsim.map(&:count).max || 0, MAX_TRACES].min + names = texts.take(n).map{ |t| t[/\d+:(.*)-/, 1].strip } + scores = texts.take(n).map{ |t| t[/\d+:(.*)-(.*)/, 2].strip } + + # Parse map data + maps = h.is_a?(Array) ? h : h.is_level? ? [h] : h.levels + tiles, object_grid, object_dict = parse_maps(maps, v, anim, trace) + + # Return full context as a hash for easy management + { + h: h, + n: n, + tiles: tiles, + object_grid: object_grid, + object_dict: object_dict, + nsim: nsim, + names: names, + scores: scores, + anim: anim, + trace: trace + } + end + + # Create an initial PNG image with the right dimensions and color to hold a screenshot + def self.init_png(palette_idx, ppc, h, cols: 1, rows: 1) + list = h.is_a?(Array) + frame = list || !h.is_level? + dim = 4 * ppc + width = dim * (COLUMNS + 2) + height = dim * (ROWS + 2) + full_width = cols * width + (cols - 1) * dim + (frame ? 2 : 0) * dim + full_height = rows * height + (rows - 1) * dim + (frame ? 2 : 0) * dim + ChunkyPNG::Image.new(full_width, full_height, PALETTE[2, palette_idx]) + end + + # Initialize the object sprites with the given palette and scale + def self.init_objects(objects, palette_idx, ppc = PPC) + scale = ppc.to_f / PPC + atlas = {} + objects.each{ |map| + map.each{ |col, hash| + hash.each{ |row, objs| + objs.map(&:first).uniq.each{ |id| + # Skip if this object doesn't exist + next if atlas.key?(id) || !OBJECTS.key?(id) + atlas[id] = {} + + # Initialize sprites for all states + OBJECTS[id][:states].times.each{ |state| + sprites = atlas[id][state] = {} + orientations = ID_LIST_DIAG.include?(id) ? [0, 1] : [0] + + # Initialize sprites for all orientations + orientations.each{ |base| + # Base sprite + sprites[base] = generate_object(id, palette_idx, true, base == 1, state) + sprites[base].resample_nearest_neighbor!( + [(scale * sprites[base].width).round, 1].max, + [(scale * sprites[base].height).round, 1].max, + ) if ppc != PPC + next if ID_LIST_FIXED.include?(id) + + # Rotated copies + sprites[base + 2] = sprites[base].rotate_right + sprites[base + 4] = sprites[base].rotate_180 + sprites[base + 6] = sprites[base].rotate_left + } + } + } + } + } + } + atlas + end + + # Initialize the tile sprites with the given palette and scale + def self.init_tiles(tiles, palette_idx, ppc = PPC) + scale = ppc.to_f / PPC + atlas = {} + tiles.each{ |map| + map.each{ |row| + row.each{ |t| + # Skip if this tile is already initialized + next if atlas.key?(t) || t <= 1 || t >= 34 + + # Initialize base tile image + o = (t - 2) % 4 # Orientation + base = t - o # Base shape + if !atlas.key?(base) + atlas[base] = generate_object(base, palette_idx, false) + atlas[base].resample_nearest_neighbor!( + (scale * atlas[base].width).round, + (scale * atlas[base].height).round, + ) if ppc != PPC + end + next if base == t + + # Initialize rotated / flipped copies + if t >= 2 && t <= 17 # Half tiles and curved slopes + case o + when 1 + atlas[t] = atlas[base].rotate_right + when 2 + atlas[t] = atlas[base].rotate_180 + when 3 + atlas[t] = atlas[base].rotate_left + end + elsif t >= 18 && t <= 33 # Small and big straight slopes + case o + when 1 + atlas[t] = atlas[base].flip_vertically + when 2 + atlas[t] = atlas[base].flip_horizontally.flip_vertically + when 3 + atlas[t] = atlas[base].flip_horizontally + end + end + } + } + } + atlas + end + + # Convert the PNG sprites to GIF format for animations + def self.convert_atlases(context_png, context_gif) + # Tile atlas + context_gif[:tile_atlas] = context_png[:tile_atlas].map{ |id, png| + png.pixels.map!{ |c| c == 0 ? TRANSPARENT_COLOR : c } + [id, png2gif(png, context_gif[:palette], TRANSPARENT_COLOR, TRANSPARENT_COLOR)] + }.to_h + + # Object atlas + context_gif[:object_atlas] = context_png[:object_atlas].map{ |id, states| + [ + id, + states.map{ |state, sprites| + [ + state, + sprites.map{ |o, png| + png.pixels.map!{ |c| c == 0 ? TRANSPARENT_COLOR : c } + [o, png2gif(png, context_gif[:palette], TRANSPARENT_COLOR, TRANSPARENT_COLOR)] + }.to_h + ] + }.to_h + ] + }.to_h + end + + # Render a list of objects onto a base image, optionally only updating a + # given bounding box (for redraws in animations). Implemented for both PNGs + # and GIFs. + def self.render_objects(objects, image, ppc: PPC, atlas: {}, bbox: nil, frame: true, cols: 5) + # Prepare scale params + dim = 4 * ppc + width = dim * (COLUMNS + 2) + height = dim * (ROWS + 2) + bbox = [0, 0, UNITS * (COLUMNS + 2), UNITS * (ROWS + 2)] if !bbox + dest_bbox = bbox2pixel(bbox, ppc) + gif = Gifenc::Image === image + + # Draw objects + off_x = frame ? dim : 0 + off_y = frame ? dim : 0 + objects.each_with_index do |map, i| + # Compose images, only for those objects intersecting the bbox + # We ignore duplicates, and sort by drawing overlap preference + gather_objects(map, bbox).uniq{ |o| o[0..4] } + .sort_by{ |o| -OBJECTS[o[0]][:pref] } + .each do |o| + # Skip objects removed or not in atlas + next if (o[4] == -1) || !(obj = atlas[o[0]]&.[](o[4])&.[](o[3])) + + # Draw differently depending on whether we have a PNG or a GIF + x = off_x + ppc * o[1] - obj.width / 2 + y = off_y + ppc * o[2] - obj.height / 2 + if !gif + image.compose!(obj, x, y) rescue nil + else + image.copy(src: obj, dest: [x, y], trans: true, bbox: dest_bbox) rescue nil + end + end + + # Adjust offsets + off_x += width + dim + if i % cols == cols - 1 + off_x = frame ? dim : 0 + off_y += height + dim + end + end + end + + # Render a list of tiles onto a base image, optionally only updating a + # given bounding box (for redraws in animations). Implemented for both PNGs + # and GIFs. + def self.render_tiles(tiles, image, ppc: PPC, atlas: {}, bbox: nil, frame: true, palette: nil, palette_idx: 0, cols: 5) + # Prepare scale params + dim = 4 * ppc + width = dim * (COLUMNS + 2) + height = dim * (ROWS + 2) + color = PALETTE[0, palette_idx] + gif = Gifenc::Image === image + color = palette[color >> 8] if gif + bbox = [0, 0, UNITS * (COLUMNS + 2), UNITS * (ROWS + 2)] if !bbox + dest_bbox = bbox2pixel(bbox, ppc) + x_min, y_min, x_max, y_max = gather_cells(bbox, false) + + # Draw tiles within the given cell range + off_x = frame ? dim : 0 + off_y = frame ? dim : 0 + tiles.each_with_index do |map, i| + (y_min .. y_max).each do |row| + (x_min .. x_max).each do |column| + t = map[row][column] + + # Empty and full tiles are handled separately + next if t == 0 + if t == 1 + x = off_x + dim * column + y = off_y + dim * row + if !gif + image.fast_rect(x, y, x + dim - 1, y + dim - 1, nil, color) + else + image.rect(x, y, dim, dim, nil, color, bbox: dest_bbox) + end + next + end + + # Compose all other tiles + next if !atlas.key?(t) + x = off_x + dim * column + y = off_y + dim * row + if !gif + image.compose!(atlas[t], x, y) rescue nil + else + image.copy(src: atlas[t], dest: [x, y], trans: true, bbox: dest_bbox) rescue nil + end + end + end + + # Adjust offsets + off_x += width + dim + if i % cols == cols - 1 + off_x = frame ? dim : 0 + off_y += height + dim + end + end + end + + # Render a list of tile borders onto a base image, optionally only updating a + # given bounding box (for redraws in animations). Implemented for both PNGs + # and GIFs. + def self.render_borders(tiles, image, palette: nil, palette_idx: 0, ppc: PPC, frame: true, bbox: nil, cols: 5) + # Prepare scale and color params + dim = 4 * ppc + width = dim * (COLUMNS + 2) + height = dim * (ROWS + 2) + thin = ppc <= 6 ? 0 : 1 + color = PALETTE[1, palette_idx] + gif = Gifenc::Image === image + color = palette[color >> 8] if gif + bbox = [0, 0, UNITS * (COLUMNS + 2), UNITS * (ROWS + 2)] if !bbox + dest_bbox = bbox2pixel(bbox, ppc) + x_min, y_min, x_max, y_max = gather_cells(bbox, true) + + # Parse borders and color + borders = BORDERS.to_i(16).to_s(2)[1..-1].chars.map(&:to_i).each_slice(8).to_a + + # Draw borders + off_x = frame ? dim : 0 + off_y = frame ? dim : 0 + tiles.each_with_index do |m, i| + # Frame surrounding entire level + if frame && !gif + image.fast_rect( + off_x, off_y, off_x + width - 1, off_y + height - 1, color, nil + ) + image.fast_rect( + off_x + 1, off_y + 1, off_x + width - 2, off_y + height - 2, color, nil + ) + end + + # Horizontal borders + (y_min ... y_max).each do |row| + (2 * x_min ... 2 * (x_max + 1)).each do |col| + tile_a = m[row][col / 2] + tile_b = m[row + 1][col / 2] + next unless (col % 2 == 0 ? (borders[tile_a][3] + borders[tile_b][6]) % 2 : (borders[tile_a][2] + borders[tile_b][7]) % 2) == 1 + x = off_x + dim / 2 * col - thin + y = off_y + dim * (row + 1) - thin + w = dim / 2 + 2 * thin + h = thin + 1 + if !gif + image.fast_rect(x, y, x + w - 1, y + h - 1, nil, color) + else + image.rect(x, y, w, h, nil, color, bbox: dest_bbox) + end + end + end + + # Vertical borders + (2 * y_min ... 2 * (y_max + 1)).each do |row| + (x_min ... x_max).each do |col| + tile_a = m[row / 2][col] + tile_b = m[row / 2][col + 1] + next unless (row % 2 == 0 ? (borders[tile_a][0] + borders[tile_b][5]) % 2 : (borders[tile_a][1] + borders[tile_b][4]) % 2) == 1 + x = off_x + dim * (col + 1) - thin + y = off_y + dim / 2 * row - thin + w = thin + 1 + h = dim / 2 + 2 * thin + if !gif + image.fast_rect(x, y, x + w - 1, y + h - 1, nil, color) + else + image.rect(x, y, w, h, nil, color, bbox: dest_bbox) + end + end + end + + # Adjust offsets + off_x += width + dim + if i % cols == cols - 1 + off_x = frame ? dim : 0 + off_y += height + dim + end + end + end + + # Redraw only a rectangular region of the screenshot. This is used for updating + # the background during animations, so that we can have some features, such as + # gold collecting or toggles toggling. Redrawing must be done in the same order + # as usual (bg -> objects -> tiles -> borders -> traces), and restricted to the + # box, otherwise we could mess other parts up. + def self.redraw_bbox(image, bbox, objects, tiles, object_atlas, tile_atlas, palette, palette_idx = 0, ppc = PPC, frame = true) + pixel_bbox = bbox2pixel(bbox, ppc) + image.rect(*pixel_bbox, nil, palette[PALETTE[2, palette_idx] >> 8]) + render_objects(objects, image, ppc: ppc, atlas: object_atlas, bbox: bbox, frame: frame) + render_tiles(tiles, image, ppc: ppc, atlas: tile_atlas, bbox: bbox, frame: frame, palette: palette, palette_idx: palette_idx) + render_borders(tiles, image, palette: palette, palette_idx: palette_idx, bbox: bbox, ppc: ppc, frame: frame) + end + + # Given a list of objects that have changed on this frame (collected gold, + # toggled mines, etc), redraw each of their corresponding bounding boxes onto + # the background. + def self.redraw_changes(image, regions, object_grid, tiles, object_atlas, tile_atlas, palette, palette_idx = 0, ppc = PPC, frame = true) + regions.each{ |bbox| + next if !bbox + bbox = bbox2game(bbox, ppc) + redraw_bbox(image, bbox, object_grid, tiles, object_atlas, tile_atlas, palette, palette_idx, ppc, frame) + } + end + + # Render the timbars with names and scores on top of animated GIFs + # TODO: Implement for static episode traces + def self.render_timebars(image, update, colors, gif: nil, info: nil) + dim = 4 * gif[:ppc] + n = info[:names].length + + n.times.each{ |i| + # Only render timebar if it has changed. In practice, this only happens + # twice: at the start, and when the ninja finishes. + next unless update[i] + + # Compute coordinates relative to the image (which need not fill the screen) + dx = (COLUMNS - 2) * dim / 4.0 + pos_x = (dim * 1.25 + i * (dim / 2.0 + dx)).round + pos_y = 1 + p = Gifenc::Geometry::Point.parse([pos_x, pos_y]) + p = Gifenc::Geometry.transform([p], image.bbox)[0] + + # Rectangle + image.rect(p.x, p.y, dx.round, dim, colors[:fg][i], colors[:bg][i], weight: 2, anchor: 0) + + # Vertical bar + image.line( + p1: [p.x + dx - dim / 2 - strlen(info[:scores][i], gif[:font]), p.y], + p2: [p.x + dx - dim / 2 - strlen(info[:scores][i], gif[:font]), p.y + dim - 1], + color: colors[:fg][i] + ) if colors[:fg][i] + + # Name + txt2gif( + info[:names][i], + image, + gif[:font], + p.x + dim / 4, + p.y + dim - 1 - 2 - 3, + colors[:text][i], + max_width: (dx - dim - strlen(info[:scores][i], gif[:font])).round + ) if colors[:text][i] + + # Score + txt2gif( + info[:scores][i], + image, + gif[:font], + p.x + dx - dim / 4, + p.y + dim - 1 - 2 - 3, + colors[:text][i], + align: :right + ) if colors[:text][i] + } + end + + # Render some informative text, such as the level ID and name like in the game + # TODO: Implement for static episode traces + def self.render_legend(image, gif, info, colors, i: nil) + # Parse highscoreable + h = info[:h] + return if h.is_episode? && !i + h = h.levels[i] if h.is_episode? + + # Level ID at the bottom left + dim = 4 * gif[:ppc] + x = dim + y = (ROWS + 2) * dim - 6 + text = h.is_userlevel? ? h.author.name.to_s : h.name + txt2gif(text, image, gif[:font], x, y, colors[:legend]) + + # Level name at the bottom right + x = (COLUMNS + 1) * dim + text = h.is_userlevel? ? h.name.to_s : h.longname + txt2gif(text, image, gif[:font], x, y, colors[:legend], align: :right) + end + + # Overlay titles (or any text) on the PNG screenshot. Since ChunkyPNG doesn't + # support text, we resort to ImageMagick as an intermediary. Thus, this + # returns a NEW image, it doesn't modify it in place. + def self.render_titles(image, titles, cols, ppc: PPC) + # Convert to Magick + image = Magick::Image.from_blob(image.to_blob(:fast_rgb)).first + + # Configure text + draw = Magick::Draw.new{ |d| + d.font = File.join(DIR_FONTS, 'sys', 'Sys.otf') + d.pointsize = 10 * ppc + d.fill = 'black' + d.font_weight = Magick::BoldWeight + d.gravity = Magick::NorthWestGravity + } + + # Overlay text + titles.each_with_index{ |text, index| + row = index / cols + col = index % cols + w = image.columns / cols + h = image.rows * cols / titles.size + x = col * w + y = row * h + draw.annotate(image, w, h, x + 8, y + 2, text) + } + + # Convert back to ChunkyPNG + ChunkyPNG::Image.from_datastream( + ChunkyPNG::Datastream.from_blob( + image.to_blob{ |img| img.format = 'PNG' } + ) + ) + end + + # Calculates the bounding box of an object's sprite based on the object data + # and the image's scale. It is returned in image coords, not game coords. + def self.find_object_bbox(o, atlas, ppc) + # Skip objects we don't have in the atlas + return nil if !atlas.key?(o[0]) + + # Find max size of sprites corresponding to this object and orientation + w = atlas[o[0]].map{ |state, sprites| sprites[o[3]]&.width }.compact.max + h = atlas[o[0]].map{ |state, sprites| sprites[o[3]]&.height }.compact.max + return nil if !w || !h + + # Calculate bounding box in pixels + x = ppc * o[1] - w / 2 + y = ppc * o[2] - h / 2 + obj_bbox = [x, y, w, h] + map_bbox = [0, 0, 4 * (COLUMNS + 2) * ppc, 4 * (ROWS + 2) * ppc] + bbox = bbox_intersect([obj_bbox, map_bbox], round: true) + return nil if !bbox + + bbox + end + + # Find the bounding box of a specific ninja marker. The marker is the circle + # that represents the ninja in an animation, and also includes the wedges for + # the input display. + def self.find_marker_bbox(frame, step, ninja, nsim, inputs = false) + # Marker coords + f = [frame + step - 1, nsim.length(ninja) - 1].min + x, y = nsim.ninja(ninja, f) + j, r, l = 0, 0, 0 + + # Extend bbox with input display, if available + if inputs && f > 0 && (input = nsim.inputs(ninja, f - 1)) + h = ANIMATION_WEDGE_HEIGHT - 1 + s = ANIMATION_WEDGE_SEP + t = ANIMATION_WEDGE_WEIGHT + add = h + s + (t + 1) / 2 + j = add if input[0] + r = add + 1 if input[1] # For some reason, it's not centered horizontally + l = add if input[2] + end + + # Compute required points, center will also be useful + rad = ANIMATION_RADIUS + { + points: [[x - rad - l, y - rad - j], [x + rad + r, y + rad]], + center: [x, y], + input: input + } + end + + # For a given frame, find the minimum region (bounding box) of the image that + # needs to be redrawn. This region must contain all points that are subject to + # change on this frame (trace bits, ninja markers, collected objects, + # timebars, input display...), and must be rectangular. + def self.find_frame_bbox(f, step, nsim, markers, regions, trace: false, inputs: false, ppc: PPC) + dim = 4 * ppc + endpoints = [] + n = nsim.count + + n.times.each do |i| + # Nothing to plot for this ninja + next if nsim.finished?(i, f, trace: trace) + + if trace # Trace chunks + _step = [step, nsim.length(i) - (f + 1)].min + (0 .. _step).each{ |s| + endpoints << nsim.ninja(i, f + s) + } + else # Ninja markers and input display + endpoints.push(*find_marker_bbox(f, step, i, nsim, inputs)[:points]) + end + + # Timebars + if nsim.just_finished?(i, f, step, trace: trace) + dx = (COLUMNS - 2) * dim / 4.0 + x = (dim * 1.25 + i * (dim / 2.0 + dx)).round + endpoints << [x, 1] + endpoints << [x + dx.round - 1, dim] + end + end + + # Collected objects + regions.each{ |obj_bbox| + next if !(x, y, w, h = obj_bbox) + endpoints << [x, y] + endpoints << [x + w - 1, y + h - 1] + } + + # Also add points from the previous frame's markers (to erase them) + endpoints.push(*markers.flatten(1)) + + # Nothing to plot, animation has finished + return if endpoints.empty? + + # Construct minimum bounding box containing all points + Gifenc::Geometry.bbox(endpoints, 1) + end + + # Redraw the background over the last frame to erase or change the + # elements that have been updated + def self.restore_background(image, background, markers, regions) + bbox = image.bbox + + # Remove ninja markers and input display from previous frame + markers.each{ |p1, p2| + image.copy( + src: background, + offset: p1, + dim: [p2[0] - p1[0] + 1, p2[1] - p1[1] + 1], + dest: Gifenc::Geometry.transform([p1], bbox)[0] + ) + } + + # Update changed elements (collected gold, toggled mines, ...) + regions.each{ |obj_bbox| + next if !(x, y, w, h = obj_bbox) + image.copy( + src: background, + offset: [x, y], + dim: [w, h], + dest: Gifenc::Geometry.transform([[x, y]], bbox)[0] + ) + } + end + + # Draw a single frame of an animated GIF. We have two modes: + # - Tracing the routes by plotting the lines. + # - Animating the ninjas by drawing moving circles. + def self.draw_frame_gif(image, frame, step, nsim, trace, colors, inputs) + bbox = image.bbox + + # Trace route bits for this frame _range_ + if trace + (0 ... step).each{ |s| + nsim.count.times.each{ |i| + next if nsim.finished?(i, frame + s, trace: true) + p1 = nsim.ninja(i, frame + s) + p2 = nsim.ninja(i, frame + s + 1) + p1, p2 = Gifenc::Geometry.transform([p1, p2], bbox) + image.line(p1: p1, p2: p2, color: colors[i], weight: 2) + } + } + return [] + end + + # Render ninja markers for this _single_ frame + rad = ANIMATION_RADIUS + e1 = Gifenc::Geometry::E1 + e2 = Gifenc::Geometry::E2 + markers = [] + nsim.count.times.reverse_each{ |i| + next if nsim.finished?(i, frame, trace: false) + + # Draw marker and save bbox to clear it on the next frame + marker_bbox = find_marker_bbox(frame, step, i, nsim, inputs) + p = Gifenc::Geometry.transform([marker_bbox[:center]], bbox)[0] + image.circle(p, rad, nil, colors[i]) rescue next + markers << marker_bbox[:points] + + # Draw input display (inputs are offset by 1 frame) + next if !(input = marker_bbox[:input]) + w = ANIMATION_WEDGE_WIDTH + h = ANIMATION_WEDGE_HEIGHT - 1 + s = ANIMATION_WEDGE_SEP + t = ANIMATION_WEDGE_WEIGHT + image.polygonal([p - e2 * (rad + s) - e1 * w, p - e2 * (rad + s + h), p - e2 * (rad + s) + e1 * w], line_color: colors[i], line_weight: t) if input[0] == 1 # Jump + image.polygonal([p + e1 * (rad + s + 1) - e2 * w, p + e1 * (rad + s + h + 1), p + e1 * (rad + s + 1) + e2 * w], line_color: colors[i], line_weight: t) if input[1] == 1 # Right + image.polygonal([p - e1 * (rad + s) - e2 * w, p - e1 * (rad + s + h), p - e1 * (rad + s) + e2 * w], line_color: colors[i], line_weight: t) if input[2] == 1 # Left + } + + markers + end + + # Draw a single frame and export to PNG. They will later be joined into an + # MP4 using FFmpeg. + def self.draw_frame_vid(image, coords, frame, colors) + coords.each_with_index{ |c_list, i| + image.line( + c_list[frame][0], + c_list[frame][1], + c_list[frame + 1][0], + c_list[frame + 1][1], + colors[i], + false, + weight: 2, + antialiasing: false + ) if coords[i].size >= frame + 2 + } + image.save("frames/#{'%04d' % frame}.png", :fast_rgb) + #`ffmpeg -framerate 60 -pattern_type glob -i 'frames/*.png' 'frames/anim.mp4' > /dev/null 2>&1` + #res = File.binread('frames/anim.mp4') + #FileUtils.rm(Dir.glob('frames/*')) + end + + # Render a PNG screenshot of a highscoreable + def self.render_screenshot(info, palette_idx, ppc, i: nil) + # Prepare highscoreable and map data + h = info[:h] + list = h.is_a?(Array) + h = h.levels[i] if i && !list + tiles = i ? [info[:tiles][i]] : info[:tiles] + objects = i ? [info[:object_grid][i]] : info[:object_grid] + + # Initialize image and sprites + vert_ep = !list && h.is_episode? && info[:vertical] + cols = list ? [IMAGESEARCH_COLS, h.size].min : h.is_level? || vert_ep ? 1 : 5 + rows = list ? (h.size.to_f / IMAGESEARCH_COLS).ceil : h.is_story? || vert_ep ? 5 : 1 + image = init_png(palette_idx, ppc, h, cols: cols, rows: rows) + tile_atlas = init_tiles(tiles, palette_idx, ppc) + object_atlas = init_objects(objects, palette_idx, ppc) + + # Compose image + unless info[:blank] + frame = list || !h.is_level? + render_objects(objects, image, ppc: ppc, frame: frame, cols: cols, atlas: object_atlas) + render_tiles( tiles , image, ppc: ppc, frame: frame, cols: cols, palette_idx: palette_idx, atlas: tile_atlas) + render_borders(tiles , image, ppc: ppc, frame: frame, cols: cols, palette_idx: palette_idx) + end + + # Overlay titles. Creates a new image, costly since we use Magick. + if info[:titles].size == (list ? h.size : 5 ** h.type_id) + image = render_titles(image, info[:titles], cols, ppc: ppc) + end + + # Return the whole context + { + image: image, + tile_atlas: tile_atlas, + object_atlas: object_atlas, + palette_idx: palette_idx, + ppc: ppc, + cols: cols, + rows: rows + } + end + + # Build a Global Color Table (a GIF palette) containing all colors from a given + # N++ palette. This is no problem, since a GIF palette can hold 256 colors, + # which is more than an N++ palette. + def self.init_gct(palette_idx) + # Include all of the palette's colors + # Note: Due to a Discord encoding bug, we must keep the transparent color + # at index 0 in the palette. + palette = PALETTE.row(palette_idx).map{ |c| c >> 8 } + table = Gifenc::ColorTable.new([TRANSPARENT_COLOR >> 8]) + table.add(*palette.uniq) + + # Add the inverted ninja colors + inverted = palette[OBJECTS[ID_NINJA][:pal], 4].map{ |c| c ^ 0xFFFFFF } + table.add(*inverted) + + # Remove duplicates and empty slots from table + table.simplify + end + + # Initialize the GIF object and its associated palette + def self.init_gif(png, info, filename, anim, delay) + # Initialize GIF palette + gct = init_gct(png[:palette_idx]) + palette = gct.colors.compact.each_with_index.to_h + ninja_colors = info[:n].times.map{ |i| + PALETTE[OBJECTS[0][:pal] + i, png[:palette_idx]] + } + + # Initialize GIF + gif = Gifenc::Gif.new(png[:image].width, png[:image].height, gct: gct) + if anim + gif.loops = -1 + gif.open(filename) + end + + # Build full context + { + gif: gif, + palette: palette, + delay: delay, + font: parse_bmfont(FONT_TIMEBAR), + colors: { + ninja: ninja_colors.map{ |c| palette[c >> 8] }, + inv: ninja_colors.map{ |c| palette[(c >> 8) ^ 0xFFFFFF] }, + trans: palette[TRANSPARENT_COLOR >> 8] + }, + palette_idx: png[:palette_idx], + ppc: png[:ppc] + } + end + + # Render the full initial background of the GIF + def self.render_gif(png, gif, info, anim: false, blank: false, i: nil) + gif[:background].destroy if gif[:background] + + # Convert PNG screenshot to GIF with specified palette + bg_color = PALETTE[2, png[:palette_idx]] + background = png2gif(png[:image], gif[:palette], bg_color) + + # Add timebars and legend + text_color = PALETTE[Palette::OFFSET_MENU + 28, png[:palette_idx]] + colors = { + fg: gif[:colors][:ninja], + bg: [gif[:palette][bg_color >> 8]] * info[:n], + text: gif[:colors][:ninja], + legend: gif[:palette][text_color >> 8] + } + skip_details = blank || !anim && !info[:h].is_level? + render_timebars(background, [true] * info[:n], colors, gif: gif, info: info) unless skip_details + render_legend(background, gif, info, colors, i: i) unless skip_details + + # No trace -> Write first frame to disk + if anim + gif[:background] = background + gif[:gif].add(background) + return + end + + # Trace -> Draw whole trace and return encoded (static) GIF + dim = 4 * gif[:ppc] + off_x = !info[:h].is_level? ? dim : 0 + off_y = off_x + info[:nsim].each{ |level| + level.count.times.reverse_each{ |i| + (level.length(i) - 1).times.each{ |f| + x1, y1 = level.ninja(i, f) + x2, y2 = level.ninja(i, f + 1) + p1 = [off_x + x1, off_y + y1] + p2 = [off_x + x2, off_y + y2] + background.line(p1: p1, p2: p2, color: gif[:colors][:ninja][i], weight: info[:h].is_level? ? 2 : 1) + } + } + off_x += dim * (COLUMNS + 3) + } + gif[:gif].images << background + gif[:gif].write + end + + # Render one frame of an animation, by redrawing the parts of the background + # that have changed (due to e.g. collecting objects), erasing the ninja + # markers from the previous frame, and drawing the elements for the new frame. + # This involves first finding the smallest bounding box containing all the + # elements that must be redrawn, to minimize the redrawn area. Returns nil + # when there's nothing else to redraw, meaning the run has finished. + def self.render_frame(frame, step, gif, info, i, markers) + # Adjust map data according to changes for this frame (e.g. remove collected gold) + regions = !info[:trace] ? think(info[:object_dict][i], info[:object_grid][i], info[:nsim][i], frame, step, gif) : [] + + # Find bounding box for this frame + bbox = find_frame_bbox(frame, step, info[:nsim][i], markers, regions, trace: info[:trace], inputs: info[:inputs], ppc: gif[:ppc]) + return if !bbox + + # Write previous frame to disk and create new frame + gif_bbox = [0, 0, gif[:gif].width, gif[:gif].height] + bbox = bbox_intersect([bbox, gif_bbox], round: true) || [0, 0, 1, 1] + image = Gifenc::Image.new( + bbox: bbox, + color: gif[:palette][TRANSPARENT_COLOR >> 8], + delay: gif[:delay], + trans_color: gif[:palette][TRANSPARENT_COLOR >> 8] + ) + + # Redraw background regions to erase markers from previous frame and + # change any objects that have been collected / toggled this frame. + if !info[:trace] + redraw_changes(gif[:background], regions, [info[:object_grid][i]], [info[:tiles][i]], gif[:object_atlas], gif[:tile_atlas], gif[:palette], gif[:palette_idx], gif[:ppc], false) unless info[:blank] + restore_background(image, gif[:background], markers, regions) + end + + # Draw new elements for this frame (trace, markers, inputs...), and save + # markers to we can delete them on the next frame + markers.pop(markers.size) + markers.push(*draw_frame_gif(image, frame, step, info[:nsim][i], info[:trace], gif[:colors][:ninja], info[:inputs])) + + # Other elements + colors = { + fg: [nil] * info[:n], + bg: gif[:colors][:ninja], + text: gif[:colors][:inv] + } + done = info[:nsim][i].count.times.map{ |j| + next false if !info[:h].is_level? && i < 4 + info[:nsim][i].just_finished?(j, frame, step, trace: info[:trace]) + } + render_timebars(image, done, colors, gif: gif, info: info) unless info[:blank] + + image + end + + # Animate all frames in the GIF, return last frame + def self.animate_gif(gif, info, i, step, memory, last, event) + frames = info[:nsim][i].length + markers = [] + image = nil + t = Time.now + (0 .. frames + step).step(step) do |f| + $frame = f + dbg("Generating frame #{'%4d' % [f + 1]} / #{frames}", newline: false) if BENCH_IMAGES + if Time.now - t > ANIM_PROGRESS_UPDATE + TmpMsg.update(event, "-# " + progress_bar(f, frames, size: 10) + " (Rendering frame #{f + 1} / #{frames})") + t = Time.now + end + frame = render_frame(f, step, gif, info, i, markers) + memory << getmem if BENCH_IMAGES + GC.start if ANIM_GC && (f / step + 1) % ANIM_GC_STEP == 0 + break if !frame + gif[:gif].add(image) if image + image = frame + end + + if image + image.delay = last ? ANIMATION_EXHIBIT : ANIMATION_EXHIBIT_INTER + gif[:gif].add(image) + end + end + + # Convert a PNG image from ChunkyPNG to a GIF from Gifenc + # This assumes we've already created a palette holding all the PNG's colors. + def self.png2gif(png, palette, bg, trans = nil) + # Create GIF frame with same dimensions and the specified background color + bg = palette[bg >> 8] + gif = Gifenc::Image.new(png.width, png.height, color: bg) + gif.trans_color = palette[trans >> 8] if trans + + # Transform color values to color indices in the palette + gif.replace(png.pixels.map{ |c| palette[c >> 8] || bg }.pack('C*')) + + gif + end + + # Generate a PNG screenshot of a level in the chosen palette. + # Optionally, plot / animate routes and export to GIF. There are 2 modes: + # - Trace mode will plot the routes as lines. Can be static or dynamic. + # - Animation mode will draw moving circles as the ninjas. + # + # Note: This function is forked to a new process to immediately free up all + # the used memory. + def self.screenshot( + theme = Palette::DEFAULT, # Palette to generate screenshot in + scale: nil, # Explicit scale, otherwise it's determined from context + vertical: false, # Render episode screenshots vertically + nsim: [], # NSim objects (simulation results) + file: false, # Whether to export to a file or return the raw data + inputs: false, # Add input display to animation + blank: false, # Only draw background + h: nil, # Highscoreable to screenshot + anim: false, # Whether to animate plotted coords or not + trace: false, # Whether the animation should be a trace or a moving object + step: ANIMATION_STEP_NORMAL, # How many frames per frame to trace + delay: ANIMATION_DELAY_NORMAL, # Time between frames, in 1/100ths sec + texts: [], # Texts for the legend + titles: [], # Texts to overlay on the screenshots + spoiler: false, # Whether the screenshot should be spoilered in Discord + v: nil, # Version of the map data to use (nil = latest) + event: nil # Originating event for this request + ) + + return nil if h.nil? + + anim = false if !FEATURE_ANIMATE + gif = !nsim.empty? + basename = h.is_a?(Array) ? 'results' : sanitize_filename(h.name.empty? ? '-' : h.name) + filename = "#{spoiler ? 'SPOILER_' : ''}#{basename}.#{gif ? 'gif' : 'png'}" + memory = [] if BENCH_IMAGES + + res = _fork do + memory << getmem if BENCH_IMAGES + + # Parse palette and scale + themes = Palette::NAMES.map(&:downcase) + palette_idx = themes.index(theme.downcase) || themes.index(Palette::DEFAULT.downcase) + ppc = scale || find_scale(h, anim) + nsim.each{ |ns| ns.ppc = ppc } + + # We will encapsulate all necessary info in a few context hashes, for easy management + context_png = nil + context_gif = nil + context_info = parse_trace(nsim, texts, h, ppc: ppc, v: v, anim: anim, trace: trace) + .merge(inputs: inputs, blank: blank, vertical: vertical, titles: titles) + res = nil + bench(:step, 'Parsing', pad_str: 12, pad_num: 9) if BENCH_IMAGES + + # Render each highscoreable + multi = !h.is_a?(Array) && h.is_episode? && gif && anim + h_list = multi ? h.levels : [h] + h_list.each_with_index{ |_, i| + # Generate PNG screenshot + context_png = render_screenshot(context_info, palette_idx, ppc, i: multi ? i : nil) + if BENCH_IMAGES + bench(:step, 'Screenshot', pad_str: 12, pad_num: 9) + memory << getmem + end + + # No routes to trace -> Done + if !gif + res = context_png[:image].to_blob(:fast_rgb) + bench(:step, 'Blobify', pad_str: 12, pad_num: 9) if BENCH_IMAGES + break + end + + # Routes to trace -> Convert to GIF + context_gif = init_gif(context_png, context_info, filename, anim, delay) if !context_gif + res = render_gif(context_png, context_gif, context_info, anim: anim, blank: blank, i: multi ? i : nil) + convert_atlases(context_png, context_gif) if anim + if BENCH_IMAGES + bench(:step, 'GIF init', pad_str: 12, pad_num: 9) + memory << getmem + end + + # No animation -> Done + break if !anim + + # Animation -> Render frames + animate_gif(context_gif, context_info, i, step, memory, i == h_list.size - 1, event) + bench(:step, 'Routes', pad_str: 12, pad_num: 9) if BENCH_IMAGES + } + + # If animated GIF, close file + if gif && anim + context_gif[:gif].close + res = File.binread(filename) + FileUtils.rm([filename]) + end + + # Finish benchmark + if BENCH_IMAGES + dbg('Image size: ' + res.size.to_s) + mem_per_frame = memory.size > 3 ? (memory[3..-1].max - memory[3..-1].min) / (memory.size - 3) : 0.0 + dbg("Memory: max #{'%.3f' % memory.max}, avg #{'%.3f' % [memory.sum / memory.size]}, per frame #{'%.3f' % mem_per_frame}") + end + + # Return binary data for PNG / GIF + res + end + + return nil if !res + file ? tmp_file(res, filename, binary: true, embed: true) : res + rescue => e + lex(e, "Failed to generate screenshot") + nil + end + + def screenshot(theme = Palette::DEFAULT, **kwargs) + Map.screenshot(theme, h: self, **kwargs) + end + + # Plot routes and legend on top of an image (typically a screenshot) + # [Depends on Matplotlib using Pycall, a Python wrapper] + # + # Note: This function is forked to a new process to immediately free up all + # the used memory. + def self.mpl_trace( + h: nil, # The highscoreable + theme: Palette::DEFAULT, # Palette to generate screenshot in + bg: nil, # Background image (screenshot) file object + nsim: nil, # NSim objects (simulation results) + texts: [], # Names for the legend + markers: { jump: true, left: false, right: false} # Mark changes in replays + ) + return if !nsim + + _fork do + # Parse palette + themes = Palette::NAMES.map(&:downcase) + theme = theme.to_s.downcase + theme = Palette::DEFAULT.downcase if !themes.include?(theme) + palette_idx = themes.index(theme) + + # Setup parameters and Matplotlib + n = [nsim.count, MAX_TRACES].min + texts = texts.take(n) + colors = n.times.map{ |i| ChunkyPNG::Color.to_hex(PALETTE[OBJECTS[0][:pal] + i, palette_idx]) } + text_color = ChunkyPNG::Color.to_hex(PALETTE[Palette::OFFSET_MENU + 28, palette_idx]) + Matplotlib.use('agg') + mpl = Matplotlib::Pyplot + mpl.ioff + + # Prepare custom font (Sys) + font = File.join(DIR_FONTS, FONT_SCREENSHOTS) + fm = PyCall.import_module('matplotlib.font_manager') + fm.fontManager.addfont(font) + mpl.rcParams['font.family'] = 'sans-serif' + mpl.rcParams['font.sans-serif'] = fm.FontProperties.new(fname: font).get_name + bench(:step, 'Trace setup', pad_str: 11) if BENCH_IMAGES + + # Configure axis + dx = (COLUMNS + 2) * UNITS + dy = (ROWS + 2) * UNITS + mpl.axis([0, dx, dy, 0]) + mpl.axis('off') + ax = mpl.gca + ax.set_aspect('equal', adjustable: 'box') + + # Load background image (screenshot) + img = mpl.imread(bg) + ax.imshow(img, extent: [0, dx, dy, 0]) + bench(:step, 'Trace image', pad_str: 11) if BENCH_IMAGES + + # Plot inputs + n.times.each{ |i| + break if markers.values.count(true) == 0 + last_coord = nil + i = n - 1 - i + nsim.length.times.each{ |j| + f = nsim.inputs(i, j) + next if !f + + if !nsim.ninja(i, j, ppc: 0) + mpl.plot(last_coord[0], last_coord[1], color: colors[i], marker: 'x', markersize: 2) if last_coord + break + else + last_coord = nsim.ninja(i, j, ppc: 0) + end + + if markers[:jump] && f[0] == 1 && (j == 0 || nsim.inputs(i, j - 1)[0] == 0) + mpl.plot(last_coord[0], last_coord[1], color: colors[i], marker: '.', markersize: 1) + end + if markers[:right] && f[1] == 1 && (j == 0 || nsim.inputs(i, j - 1)[1] == 0) + mpl.plot(last_coord[0], last_coord[1], color: colors[i], marker: '>', markersize: 1) + end + if markers[:left] && f[2] == 1 && (j == 0 || nsim.inputs(i, j - 1)[2] == 0) + mpl.plot(last_coord[0], last_coord[1], color: colors[i], marker: '<', markersize: 1) + end + } + } + bench(:step, 'Trace input', pad_str: 11) if BENCH_IMAGES + + # Plot timebars at the top + n.times.each{ |i| + break if texts[i].nil? + name, score = texts[i].match(/(.*)-(.*)/).captures.map(&:strip) + dx = UNITS * COLUMNS / 4.0 + ddx = UNITS / 2 + bx = UNITS / 4 + c = 8 + m = dx / 2.9 + dm = 4 + x, y = UNITS + dx * i, UNITS - 5 + vert_x = [x + bx, x + bx, x + bx + c, x + dx - m - dm, x + dx - m, x + dx - m + dm, x + dx - bx - c, x + dx - bx, x + dx - bx] + vert_y = [2, UNITS - c - 2, UNITS - 2, UNITS - 2, UNITS - dm - 2, UNITS - 2, UNITS - 2, UNITS - c - 2, 2] + color_bg = ChunkyPNG::Color.to_hex(PALETTE[2, palette_idx]) + color_bd = colors[i] + mpl.fill(vert_x, vert_y, facecolor: color_bg, edgecolor: color_bd, linewidth: 0.5) + mpl.text(x + ddx, y, name, ha: 'left', va: 'baseline', color: colors[i], size: 'x-small') + mpl.text(x + dx - ddx, y, score, ha: 'right', va: 'baseline', color: colors[i], size: 'x-small') + } + + # Plot legend at the bottom + y = (ROWS + 2) * UNITS - 5 + text = h.is_userlevel? ? h.author.name.to_s : h.name + mpl.text(UNITS, y, text, ha: 'left', va: 'baseline', color: text_color, size: 'x-small') + text = h.is_userlevel? ? h.name.to_s : h.longname + mpl.text((COLUMNS + 1) * UNITS, y, text, ha: 'right', va: 'baseline', color: text_color, size: 'x-small') + bench(:step, 'Trace texts', pad_str: 11) if BENCH_IMAGES + + # Plot traces + n.times.each{ |i| + j = n - 1 - i + coords = nsim.length(j).times.map{ |f| nsim.ninja(j, f) }.transpose + mpl.plot(coords[0], coords[1], colors[j], linewidth: 0.5) + } + bench(:step, 'Trace plot', pad_str: 11) if BENCH_IMAGES + + # Save result + fn = tmp_filename("#{name}_aux.png") + mpl.savefig(fn, bbox_inches: 'tight', pad_inches: 0, dpi: 390, pil_kwargs: { compress_level: 1 }) + image = File.binread(fn) + bench(:step, 'Trace save', pad_str: 11) if BENCH_IMAGES + + # Perform cleanup + mpl.cla + mpl.clf + mpl.close('all') + + # Return + image + end + rescue + nil + end + + def self.trace(event, anim: false, h: nil) + # Parse message parameters + bench(:start) if BENCH_IMAGES + t = Time.now + h = parse_highscoreable(event, mappack: true) if !h + perror("Failed to parse highscoreable.") if !h + perror("Columns can't be traced.") if h.is_story? + perror("This trace is disabled, figure it out yourself!") if h.is_protected? + msg = parse_message(event) + hash = parse_palette(event) + msg, palette, error = hash[:msg], hash[:palette], hash[:error] + h = h.vanilla if h.is_mappack? && h.mappack.id == 0 + perror("Error finding Metanet board.") if !h + userlevel = h.is_userlevel? + board = parse_board(msg, 'hs') + perror("Non-highscore modes (e.g. speedrun) are only available for mappacks.") if !h.is_mappack? && board != 'hs' + perror("Traces are only available for either highscore or speedrun mode.") if !['hs', 'sr'].include?(board) + if userlevel + TmpMsg.update(event, "-# Updating scores and downloading replays...") + h.update_scores(fast: true) + end + frac = (parse_frac(msg) || h.is_mappack? && h.mappack.fractional) && h.is_level? + leaderboard = h.leaderboard(board, pluck: false, frac: frac) + ranks = parse_ranks(msg, leaderboard.size).take(MAX_TRACES) + scores = ranks.map{ |r| leaderboard[r] }.compact + perror("No scores found in this board.") if scores.empty? + blank = !!msg[/\bblank\b/i] + markers = { jump: false, left: false, right: false } if !!msg[/\bplain\b/i] + markers = { jump: true, left: true, right: true } if !!msg[/\binputs\b/i] + markers = { jump: true, left: false, right: false } if markers.nil? + if !!msg[/very\s+slow/i] + delay = ANIMATION_DELAY_VSLOW + elsif !!msg[/slow/i] + delay = ANIMATION_DELAY_SLOW + else + delay = ANIMATION_DELAY_NORMAL + end + if !!msg[/very\s+fast/i] + step = ANIMATION_STEP_VFAST + elsif !!msg[/fast/i] + step = ANIMATION_STEP_FAST + else + step = ANIMATION_STEP_NORMAL + end + debug = !!msg[/\bdebug\b/i] && check_permission(event, 'ntracer') + full = !!msg[/\bfull\b/i] || !!msg[/\bcomplete\b/i] + gif = anim || !h.is_level? + trace = !!msg[/\btrace\b/i] + channel = event.channel + spoiler = parse_spoiler(msg, h, channel) + + # Prepare demos + demos_dec = scores.map{ |score| + userlevel ? score.demo : score.demo.decode(true) + }.transpose + demos_enc = demos_dec.map{ |lvl| lvl.map{ |demo| Demo.encode(demo) } } + bench(:step, 'Setup', pad_str: 12, pad_num: 9) if BENCH_IMAGES + + # Execute simulation and parse result + TmpMsg.update(event, '-# Running simulation...') + if userlevel + complexity = h.complexity * demos_dec.first.map(&:size).max + else + complexity = scores.map{ |score| score.demo.complexity }.max + end + dbg("NSim run complexity: #{complexity}.") + full_old = full + full = true if complexity < ANIM_LIMIT_SOFT + full = false if complexity > ANIM_LIMIT_HARD || !anim || trace + res = h.levels.each_with_index.map{ |l, i| NSim.new(l.map.dump_level, demos_enc[i]) } + res.each{ |nsim| + nsim.run(basic_sim: !full, basic_render: !full) + bench(:step, 'Simulation', pad_str: 12, pad_num: 9) if BENCH_IMAGES + } + complexity = res.map(&:complexity).sum + if complexity > ANIM_LIMIT_HARD || complexity > ANIM_LIMIT_SOFT && !full_old + full = false + res.each(&:clear_coords) + end + + # Check simulation success + all_success = res.all?(&:success) + all_correct = res.all?(&:correct) + all_valid = res.all?(&:valid) + if !all_success || !all_correct + if !all_success + str = "Simulation failed, contact the botmaster for details. " + else + str = "Failed to parse simulation result, contact the botmaster for details. " + end + res.each{ |l| str << l.dbg(event) } if debug + perror(str) + end + + # Prepare output message + acquire_connection + names = scores.map{ |s| s.player.print_name } + valids = res.map{ |l| l.valid_flags }.transpose.map{ |s| s.all?(true) } + wrong_names = names.each_with_index.select{ |_, i| !valids[i] }.map(&:first) + event << error.strip if !error.empty? + header = "Replay #{format_board(board)} #{'trace'.pluralize(names.count)}" + header << " for #{names.to_sentence}" + header << " in #{userlevel ? "userlevel #{verbatim(h.name)} by #{verbatim(h.author.name)}" : h.name}" + header << " using palette #{verbatim(palette)}:" + event << header + texts = h.format_scores(np: gif ? 0 : 11, mode: board, ranks: ranks, join: false, frac: frac, dev: false, prec: frac ? 3 : -1, flags: false) + if !all_valid + warning = "**Warning**: #{'Trace'.pluralize(wrong_names.count)}" + warning << " for #{wrong_names.to_sentence}" + warning << " #{wrong_names.count == 1 ? 'is' : 'are'} likely incorrect." + event << mdtext(warning, header: -1) + end + if full_old && !full && anim + reason = case + when trace + 'not available for traces' + else + 'complexity too high' + end + event << mdtext("**Note**: Entity animation disabled (#{reason}).", header: -1) + end + + # Render trace or animation + output = ''.b + test = false + if !test && gif + TmpMsg.update(event, '-# Animating...') + sleep(0.05) while !TmpMsg.fetch(event).sent? # Wait till first msg is sent before forking, otherwise problems! + output = screenshot( + palette, + h: h, + trace: trace, + nsim: res, + texts: texts, + anim: anim, + blank: blank, + inputs: ANIMATION_DEFAULT_INPUT || !!msg[/\binputs?\b/i], + step: step, + delay: delay, + event: event + ) + perror('Failed to render animation') if output.nil? + elsif !test && !gif + TmpMsg.update(event, '-# Plotting routes...') + screenshot = h.map.screenshot(palette, file: true, blank: blank) + perror('Failed to render screenshot') if screenshot.nil? + $trace_context = { + h: h, + theme: palette, + bg: screenshot, + nsim: res.first, + markers: markers, + texts: !blank ? texts : [] + } + output = QueuedCmd.new(:trace).enqueue + screenshot.close + perror('Failed to trace replays') if output.nil? + end + + # Send image file + ext = gif ? 'gif' : 'png' + fn = "#{name}_#{ranks.map(&:to_s).join('-')}_trace.#{ext}" + send_file(event, output, fn, true, spoiler) unless test + + # Free allocated resources + res.each{ |nsim| nsim.destroy } + res.map!{ nil } + res.clear + output.clear + + # Output debug info + dbg("NSim memory used: %dMB." % getmem) + dbg("NSim full time: %.3fs." % [Time.now - t]) + event << res.map{ |l| l.dbg(event) }.join("\n\n") if debug + dbg("FINAL: #{"%8.3f" % [1000 * (Time.now - t)]}") if BENCH_IMAGES + rescue => e + lex(e, 'Failed to trace replays', event: event) + end + + # Tests whether ntrace is working with this level or not + def test_ntrace(ranks: [0], board: 'hs', frac: false) + leaderboard = vanilla.leaderboard(board, pluck: false, frac: frac) + scores = ranks.map{ |r| leaderboard[r] }.compact + return :other if scores.empty? + demos = scores.map{ |s| s.demo.demo } + return :other if demos.count(nil) > 0 + nsim = NSim.new(dump_level, demos) + nsim.run(basic_sim: false, basic_render: false, silent: true) + return :error if !nsim.success + return :other if !nsim.correct + return nsim.valid ? :good : :bad + rescue => e + lex(e, 'ntrace testing failed') + nil + end +end + +# Each object corresponds to an in-game color palette. An N++ palette defines a +# total of 195 colors, some of which are unused in the game. They are stored as +# 35 RGB TGA image files, inside a folder whose name is the in-game palette +# name. The official palettes are instead baked into the game files, and their +# names are reserved. Alpha layer appears to be ignored, but we store it. +# +# In the db, each color is stored packed as RGBA into a 4-byte integer, and all +# colors in order (see FILES) are packed into a 780-byte binary string. +# See also: +# - Format documentation: +# * https://pastebin.com/E01pEhy4 +# * https://www.youtube.com/watch?v=Ql8gf6PPcUY +# - Palette creation tools: +# * https://coloration-cimn.onrender.com/ +# * https://edelkas.github.io/npc-web/ +# - Custom palettes: +# * https://docs.google.com/spreadsheets/d/1I2f87Qhfs6rxzZq5dQRDbLKYyaGLqTdCkLqfNfrw1Mk +# * https://github.com/psenough/NPlusPlusAssistant/tree/palettes +# * https://github.com/Donfuy/plusplus-custom-palettes +class Palette < ActiveRecord::Base + belongs_to :mappack + belongs_to :user + + # The 129 official palettes. The first 62 are from Pre-UE, the next 61 from UE, + # and the final 6 from TEN++. The first 123 (Pre-UE + UE) are baked into the game, + # and their order matches this one. This is relevant e.g. for indexing into the + # nprofile. The final 6 were included as standard custom palettes. + NAMES = [ + 'BASIC', 'F7200', 'acid', 'airline', + 'birthday cake', 'blueprint', 'bordeaux', 'chemical', + 'chococherry', 'classic', 'clean', 'console', + 'disassembly', 'dorado', 'dusk', 'epaper', + 'epaper invert', 'evening', 'galactic', 'gothmode', + 'holosphere', 'hot', 'infographic', 'invert', + 'kicks', 'lightcycle', 'm', 'metoro', + 'midnight', 'minus', 'mir', 'mono', + 'moonbase', 'neptune', 'oceanographer', 'okinami', + 'orbit', 'pale', 'papier', 'papier invert', + 'party', 'pinku', 'plus', 'poseidon', + 'pulse', 'quench', 'replicant', 'retro', + 'shift', 'shock', 'simulator', 'solarized dark', + 'solarized light', 'supernavy', 'toxin', 'vasquez', + 'virtual', 'vivid', 'wizard', 'yeti', + 'pumpkin', 'witchy', 'argon', 'autumn', + 'berry', 'bloodmoon', 'brink', 'cacao', + 'champagne', 'concrete', 'cowboy', 'dagobah', + 'debugger', 'delicate', 'desert world', 'elephant', + 'florist', 'formal', 'gatecrasher', 'grapefrukt', + 'grappa', 'gunmetal', 'hazard', 'heirloom', + 'hope', 'hyperspace', 'ice world', 'incorporated', + 'jaune', 'juicy', 'lab', 'lava world', + 'lemonade', 'lichen', 'line', 'machine', + 'mustard', 'mute', 'nemk', 'neutrality', + 'noctis', 'petal', 'PICO-8', 'porphyrous', + 'QDUST', 'regal', 'rust', 'sakura', + 'sinister', 'starfighter', 'sunset', 'synergy', + 'talisman', 'toothpaste', 'TR-808', 'tycho', + 'vectrex', 'vintage', 'void', 'waka', + 'wyvern', 'xenon', 'powder', 'anniversary', + 'burple', 'classy', 'SNKRX', 'ten', + 'tropical' + ] + DEFAULT = "vasquez" + + # The 35 TGA swatch files that constitute an in-game palette + FILES = { + "background" => { position: 0, size: 6 }, + "ninja" => { position: 1, size: 4 }, + "entityMine" => { position: 2, size: 4 }, + "entityGold" => { position: 3, size: 3 }, + "entityDoorExit" => { position: 4, size: 8 }, + "entityDoorExitSwitch" => { position: 5, size: 5 }, + "entityDoorRegular" => { position: 6, size: 1 }, + "entityDoorLocked" => { position: 7, size: 8 }, + "entityDoorTrap" => { position: 8, size: 8 }, + "entityLaunchPad" => { position: 9, size: 2 }, + "entityOneWayPlatform" => { position: 10, size: 2 }, + "entityDroneChaingun" => { position: 11, size: 2 }, + "entityDroneLaser" => { position: 12, size: 4 }, + "entityDroneZap" => { position: 13, size: 2 }, + "entityDroneChaser" => { position: 14, size: 2 }, + "entityFloorGuard" => { position: 15, size: 2 }, + "entityBounceBlock" => { position: 16, size: 2 }, + "entityRocket" => { position: 17, size: 4 }, + "entityTurret" => { position: 18, size: 5 }, + "entityThwomp" => { position: 19, size: 3 }, + "entityEvilNinja" => { position: 20, size: 2 }, + "entityDualLaser" => { position: 21, size: 2 }, + "entityBoostPad" => { position: 22, size: 2 }, + "entityBat" => { position: 23, size: 3 }, + "entityEyeBat" => { position: 24, size: 2 }, + "entityShoveThwomp" => { position: 25, size: 3 }, + "headbands" => { position: 26, size: 17 }, + "explosions" => { position: 27, size: 4 }, + "timeBar" => { position: 28, size: 8 }, + "timeBarRace" => { position: 29, size: 17 }, + "fxNinja" => { position: 30, size: 2 }, + "fxDroneZap" => { position: 31, size: 2 }, + "fxFloorguardZap" => { position: 32, size: 2 }, + "menu" => { position: 33, size: 42 }, + "editor" => { position: 34, size: 10 } + } + COLOR_COUNT = FILES.values.sum{ |v| v[:size] } # 195 + SWATCH_BLOCK_SIZE = 64 # Each swatch color block is 64x64 pixels + SWATCH_SAMPLE_X = 32 # The color is sampled from column 32 of each block + SWATCH_SAMPLE_Y = 32 # The color is sampled from row 32 FROM THE BOTTOM (31 from the top, usually) of each block + + # TAG header specification, for parsing files + TGA_HEADER_SIZE = 18 + TGA_HEADER_FORMAT = 'C3S<2CS<4C2' + TGAHeader = Struct.new( + :id_length, :colormap_type, :image_type, # (3 bytes) + :cm_origin, :cm_length, :cm_depth, # Color map specification (5 bytes) + :origin_x, :origin_y, :width, :height, :depth, :desc # Image specification (10 bytes) + ) + TGA_TYPE_NONE = 0 + TGA_TYPE_COLORMAPPED = 1 + TGA_TYPE_TRUECOLOR = 2 + TGA_TYPE_GRAYSCALE = 3 + TGA_TYPE_RLE_COLORMAPPED = 9 + TGA_TYPE_RLE_TRUECOLOR = 10 + TGA_TYPE_RLE_GRAYSCALE = 11 + TGA_FOOTER = "TRUEVISION-XFILE.\x00" + + # X offsets (0-194) for each color section in the master palette file + OFFSET_TILES = 0 + OFFSET_ENTITIES = 6 + OFFSET_HEADBANDS = 91 + OFFSET_EXPLOSIONS = 108 + OFFSET_TIMEBAR = 112 + OFFSET_TIMEBAR_RACE = 120 + OFFSET_FX_NINJA = 137 + OFFSET_FX_DRONE = 139 + OFFSET_FX_FLOORGUARD = 141 + OFFSET_MENU = 143 + OFFSET_EDITOR = 185 + + # Parse one TGA swatch. Each swatch is a horizontal strip of 64x64 blocks. + # We store pixel data bottom to top, as is conventional for TGA. The game only + # samples the middle pixel of each block, and ignores the alpha layer if present. The + # original palettes were raw, whereas the TEN++ ones are RLE'd, so we need to support + # both, but we can ignore color-mapped ones. + # Returns an array of colors, each as a 4-byte integer (RGBA), or nil if error. + def self.parse_file(path) + return err("TGA file not found") unless File.file?(path) + file = File.open(path, 'rb') + + # Parse TGA properties + tga = TGAHeader.new(*file.read(TGA_HEADER_SIZE).unpack(TGA_HEADER_FORMAT)) + total_pixels = tga.width * tga.height + num_colors, pixel_size = tga.width / SWATCH_BLOCK_SIZE, tga.depth / 8 + flip_x, flip_y, interlace = tga.desc[4] == 1, tga.desc[5] == 1, tga.desc[6, 2] + + # Sanity checks + return err("Only RGB images are supported.") if ![TGA_TYPE_TRUECOLOR, TGA_TYPE_RLE_TRUECOLOR].include?(tga.image_type) + return err("Color-mapped images not supported.") if tga.colormap_type != 0 + return err("Interlaced images not supported.") if interlace > 0 + return err("Pixel depth isn't a multiple of 8.") if tga.depth % 8 != 0 + return err("Pixel depth cannot hold true color.") if tga.depth < 24 + return err("Colormap depth isn't a multiple of 8.") if tga.cm_depth % 8 != 0 + + # Read pixel data + file.seek(tga.id_length + tga.cm_length * tga.cm_depth / 8, IO::SEEK_CUR) + pixel_data = file.read(total_pixels * pixel_size) + file.close + + # Decode RLE + if tga.image_type == TGA_TYPE_RLE_TRUECOLOR + buffer = StringIO.new(pixel_data) + pixels = ''.b + pixel_count = 0 + loop do + header_byte = buffer.read(1) + break unless header_byte + packet_header = header_byte.unpack1('C') + packet_size = (packet_header & 0x7F) + 1 + if (packet_header & 0x80) == 0 # Raw packet + pixels << buffer.read(packet_size * pixel_size) + else # RLE packet + pixels << buffer.read(pixel_size) * packet_size + end + pixel_count += packet_size + break if pixel_count >= total_pixels || buffer.eof? + end + pixel_data = pixels + end + return err("Corrupt pixel data") if pixel_data.size != total_pixels * pixel_size + + # Pixel data may be flipped + if flip_x + pixel_data = pixel_data.unpack("a#{pixel_size}" * total_pixels).each_slice(tga.width).map{ |row| row.reverse.join } + pixel_data.reverse! if flip_y + pixel_data = pixel_data.join + elsif flip_y + pixel_data = pixel_data.unpack("a#{pixel_size * tga.width}" * tga.height).reverse.join + end + + # The game samples the "middle" pixel (32, 32) of each 64x64 block from the bottom + offset = SWATCH_SAMPLE_Y * pixel_size * tga.width + SWATCH_SAMPLE_X * pixel_size + step = SWATCH_BLOCK_SIZE * pixel_size + num_colors.times.map{ |i| + b, g, r, a = pixel_data.unpack("C#{pixel_size}", offset: offset + step * i) + r << 24 | g << 16 | b << 8 | (a || 0xFF) + } + end + + # Given the path of a palette folder with all 35 TGA swatches, returns an array + # with all 195 colors in the order of the master palette (see FILES), each packed + # as a 4-byte integer (RGBA). Note though that the game ignores the alpha layer. + # Returns nil if any file fails to parse. + def self.parse_palette(path) + FILES.map{ |k, v| + colors = parse_file(File.join(path, k + '.tga')) + return err("Swatch #{k} doesn't have #{v[:size]} colors") if colors.size != v[:size] + colors + }.flatten + end + + # Parse all 129 official palettes from the game files and generate the master + # palette PNG image that ships with outte. Returns true if successful, nil otherwise. + def self.generate_master_palette(path, output) + png = ChunkyPNG::Image.new(COLOR_COUNT, NAMES.size + 1, ChunkyPNG::Color::WHITE) + NAMES.each_with_index{ |palette, i| + dbg("Parsing palette #{i + 1} / #{NAMES.size}: #{palette}", progress: true) + colors = parse_palette(File.join(path, palette)) + return err("Failed to parse palette #{palette}") unless colors + png.replace_row!(i, colors) + } + png.save(output, :fast_rgb) + log("Generated master palette", pad: true) + true + end + + # Create Palette objects for all 129 official Metanet palettes. Since we don't + # have the actual files, we read from the shipped master palette PNG. + def self.seed_metanet_palettes + metanet = Mappack.find(0) + NAMES.each_with_index{ |palette, i| + metanet.add_palette(palette, Map::PALETTE.row(i).pack('L>*')) + } + end + + # Generate the 35 TGA v2 swatches + def export(path: '.') + dir = File.join(path, sanitize_filename(name)) + FileUtils.mkdir_p(dir) unless Dir.exist?(dir) + buffer = StringIO.new(colors) + FILES.each{ |k, v| + # Dump header + tga = [ + 0, 0, TGA_TYPE_RLE_TRUECOLOR, # ID length, color map type, image type + 0, 0, 0, # Color map specification + 0, 0, SWATCH_BLOCK_SIZE * v[:size], SWATCH_BLOCK_SIZE, 24, 0 # X, Y, W, H, depth, descriptor + ].pack(TGA_HEADER_FORMAT) + + # RLE with one run per color + tga << v[:size].times.map{ |color| + r, g, b, a = buffer.read(4).unpack('C4') + [0x80 | SWATCH_BLOCK_SIZE - 1, b, g, r].pack('C4') + }.join * SWATCH_BLOCK_SIZE + + # Dump footer + tga << [0, 0, TGA_FOOTER].pack('L<2a*') + File.binwrite(File.join(dir, k + '.tga'), tga) + } + end +end + +# Done loading file +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) diff --git a/src/messages.rb b/src/messages.rb new file mode 100644 index 00000000..7d9bc704 --- /dev/null +++ b/src/messages.rb @@ -0,0 +1,2607 @@ +# This file handles all the direct communication of outte with the server, i.e., +# the responses to all commands sent via Discord pings or DMs. See the "respond" +# method at the end to start to understand the flow. + +$load_time = Time.now + +require 'rmagick' +require 'svggraph' +require 'zip' + +# Prints COUNT of scores with specific characteristics for a player. +# Arg 'file': Also return list of scores in a text file. +# Arg 'missing': Return complementary list, i.e., those NOT verifying conditions +# Arg 'third': Allows to parse player name using 'is' +def send_list(event, file = true, missing = false, third = false) + # Parse message parameters + msg = parse_message(event) + player = parse_player(event, false, false, false, false, third) + msg = msg.remove!(player.name) + mappack = parse_mappack(event: event) + board = parse_board(msg, 'hs') + dev = parse_dev(msg) && !mappack.nil? && ['hs', 'sr'].include?(board) + type = parse_type(msg, default: dev ? Level : nil) + tabs = parse_tabs(msg) + cool = mappack.nil? ? parse_cool(msg) : false + star = mappack.nil? ? parse_star(msg) : false + range = parse_range(msg, cool || star || missing || dev, dev) + ties = parse_ties(msg) + tied = parse_tied(msg) + sing = (missing ? -1 : 1) * parse_singular(msg) + high = missing && !(sing != 0 || cool || star) # list of highscoreables, not scores + perror("Speedrun mode isn't available for Metanet levels yet.") if board == 'sr' && !mappack + + # The range must make sense + if !range[2] + event << "You specified an empty range! (#{format_range(range[0], range[1])})" + return + end + + # Retrieve score count with specified characteristics + if sing != 0 + list = player.singular(type, tabs, sing == 1 ? false : true) + else + list = player.range_ns(range[0], range[1], type, tabs, ties, tied, cool, star, missing, mappack, board, dev, join: !high) + end + + # Format list + if !high + c = mappack ? 'Mappack' : '' + t = mappack ? 'mappack_' : '' + name = <<~STR.gsub(/\s+/, ' ').strip + CASE + WHEN `#{t}scores`.`highscoreable_type` = '#{c}Level' THEN `#{t}levels`.`name` + WHEN `#{t}scores`.`highscoreable_type` = '#{c}Episode' THEN `#{t}episodes`.`name` + WHEN `#{t}scores`.`highscoreable_type` = '#{c}Story' THEN `#{t}stories`.`name` + ELSE '' + END + STR + rank = !mappack ? '`rank`' : "`rank_#{board}`" + score = !mappack ? 'ROUND(`score` * 60)' : "`score_#{board}`" + score = "REPLACE(FORMAT(#{score} / 60, 3), ',', '')" if !board || board == 'hs' + pad_rank = 2 + pad_name = !mappack ? 10 : 14 + pad_score = !board || board == 'hs' ? 8 : 4 + fields = [] + fields << "LPAD(#{rank}, #{pad_rank}, '0')" unless board == 'gm' + fields << "': '" unless board == 'gm' + fields << "RPAD(#{name}, #{pad_name}, ' ')" + fields << "' - '" unless board == 'gm' + fields << "LPAD(#{score}, #{pad_score}, ' ')" unless board == 'gm' + list = list.pluck("CONCAT(#{fields.join(', ')})").uniq + end + + # Format header + max1 = find_max(:rank, type, tabs, false, mappack, board, dev) + max2 = player.range_ns(range[0], range[1], type, tabs, ties, tied).count + full = !missing || !(cool || star) # max is all scores, not all player's scores + max = full ? max1 : max2 + type = format_type(type).downcase + tabs = format_tabs(tabs) + range = format_range(range[0], range[1], sing != 0 || board == 'gm') + sing = format_singular((missing ? -1 : 1) * sing) + cool = format_cool(cool) + star = format_star(star) + ties = format_ties(ties) + tied = format_tied(tied) + dev = format_dev(dev) + boardB = !mappack.nil? ? format_board(board) : '' + mappackB = format_mappack(mappack) + count = list.count + header = "#{player.print_name} #{missing ? 'is missing' : 'has'} " + header << "#{count} out of #{max} #{cool} #{tied} #{dev} #{boardB} #{tabs} #{type} " + header << "#{range}#{star} #{sing} scores #{ties} #{mappackB}" + event << format_header(header, close: '.', upcase: false) + + # Optionally export list in file + return unless file + if count <= 20 + event << format_block(list.join("\n")) + else + send_file(event, list.join("\n"), "scores-#{player.sanitize_name}.txt", false) + end +rescue => e + lex(e, "Error performing #{file ? 'list' : 'count'}.", event: event) +end + +# Return list of players sorted by a number of different ranking types +# Navigation controls are optional +# The named parameters are ALL for the navigation: +# 'page' Controls the page of the rankings button navigation +# 'type' Type buttons (i.e., Level, Episode, Story) +# 'tab' Tab select menu option (All, SI, S, SU, SL, ?, !) +# 'rtype' Ranking type select menu option +# 'ties' Ties button +# When a named parameter is not nil, then that button/select menu was pressed, +# so it takes preference, and is used instead of parsing it from the message +def send_rankings(event, page: nil, type: nil, tab: nil, rtype: nil, ties: nil) + # PARSE ranking parameters (from function arguments and message) + initial = parse_initial(event) + reset_page = !type.nil? || !tab.nil? || !rtype.nil? || !ties.nil? + msg = parse_message(event) + tabs = parse_tabs(msg, tab) + tab = tabs.empty? ? 'all' : (tabs.size == 1 ? tabs[0].to_s.downcase : 'tab') + ties = !ties.nil? ? ties : parse_ties(msg, rtype) + play = parse_many_players(msg) + nav = parse_nav(msg) || !initial + full = !!msg[/global/i] || parse_full(msg) || nav + cool = !rtype.nil? && parse_cool(rtype) || rtype.nil? && parse_cool(msg) + star = !rtype.nil? && parse_star(rtype, false, true) || rtype.nil? && parse_star(msg, true, true) + maxed = !rtype.nil? && parse_maxed(rtype) || rtype.nil? && parse_maxed(msg) + maxable = !maxed && (!rtype.nil? && parse_maxable(rtype) || rtype.nil? && parse_maxable(msg)) + rtype2 = rtype # save a copy before we change it + rtype = rtype || parse_rtype(msg) + whole = [ + 'average_point', + 'average_rank', + 'point', + 'score', + 'cool', + 'star', + 'maxed', + 'maxable', + 'G++', + 'G--' + ].include?(rtype) # default rank is top20, not top1 (0th) + mappack = parse_mappack(event: event) + board = parse_board(msg, 'hs') + board = 'hs' if !['hs', 'sr'].include?(board) + dev = parse_dev(msg) && !mappack.nil? + whole ||= dev + range = !parse_rank(rtype).nil? ? [0, parse_rank(rtype), true] : parse_range(rtype2.nil? ? msg : '', whole, dev) + rtype = fix_rtype(rtype, range[1]) + def_level = [ + 'score', + 'G++', + 'G--' + ].include?(rtype) || dev # default type is Level + type = parse_type(msg, type: type, multiple: true, initial: initial, default: def_level ? Level : nil) + frac = parse_frac(msg, mappack, board) + frac &&= ['average_top1_lead', 'score'].include?(rtype) + + perror("Speedrun mode isn't available for Metanet levels yet.") if board == 'sr' && !mappack + perror("Gold rankings aren't available for Metanet levels yet.") if ['G++', 'G--'].include?(rtype) && !mappack + + # The range must make sense + if !range[2] + event << "You specified an empty range! (#{format_range(range[0], range[1])})" + return + end + + # Determine ranking type and max value of said ranking + rtag = :rank + case rtype + when 'average_point' + rtag = :avg_points + max = find_max(:avg_points, type, tabs, !initial, mappack, board, dev, frac) + when 'average_top1_lead' + rtag = :avg_lead + max = nil + when 'average_rank' + rtag = :avg_rank + max = find_max(:avg_rank, type, tabs, !initial, mappack, board, dev, frac) + when 'point' + rtag = :points + max = find_max(:points, type, tabs, !initial, mappack, board, dev, frac) + when 'score' + rtag = :score + max = find_max(:score, type, tabs, !initial, mappack, board, dev, frac) + when 'singular_top1' + rtag = :singular + max = find_max(:rank, type, tabs, !initial, mappack, board, dev, frac) + range[1] = 1 + when 'plural_top1' + rtag = :singular + max = find_max(:rank, type, tabs, !initial, mappack, board, dev, frac) + range[1] = 0 + when 'tied_top1' + rtag = :tied_rank + max = find_max(:rank, type, tabs, !initial, mappack, board, dev, frac) + when 'maxed' + rtag = :maxed + max = find_max(:maxed, type, tabs, !initial, mappack, board, dev, frac) + when 'maxable' + rtag = :maxable + max = find_max(:maxable, type, tabs, !initial, mappack, board, dev, frac) + when 'G++' + rtag = :gp + max = find_max(:gp, type, tabs, !initial, mappack, board, dev, frac) + when 'G--' + rtag = :gm + max = find_max(:gm, type, tabs, !initial, mappack, board, dev, frac) + else + rtag = :rank + max = find_max(:rank, type, tabs, !initial, mappack, board, dev, frac) + end + + # EXECUTE specific rankings + rank = Score.rank( + ranking: rtag, # Ranking type. Def: Regular scores. + type: type, # Highscoreable type. Def: Levels and episodes. + tabs: tabs, # Highscoreable tabs. Def: All tabs (SI, S, SU, SL, ?, !). + players: play, # Players to ignore. Def: None. + a: range[0], # Bottom rank of scores. Def: 0th. + b: range[1], # Top rank of scores. Def: 19th. + ties: ties, # Include ties or not. Def: No. + cool: cool, # Only include cool scores. Def: No. + star: star, # Only include * scores. Def: No. + dev: dev, # Only over-DEV scores. Def: No. + frac: frac, # Use fractional scores. Def: No. + mappack: mappack, # Mappack to do rankings. Def: None. + board: board # Highscore or speedrun. Def: Highscore. + ) + + # PAGINATION + pagesize = nav ? PAGE_SIZE : 20 + page = parse_page(msg, page, reset_page, event.message.components) + pag = compute_pages(rank.size, page, pagesize) + + # FORMAT message + min = '' + if ['average_rank', 'average_point'].include?(rtype) + min_scores = min_scores(type, tabs, !initial, range[0], range[1], star, mappack) + min = " __Min. scores__: **#{min_scores}**." + end + # --- Header + no_range = [ # Don't print range for these rankings + 'tied_top1', + 'singular_top1', + 'plural_top1', + 'average_top1_lead', + 'score', + 'G++', + 'G--' + ].include?(rtype) + no_board = [ # Don't print board for these rankings + 'G++', + 'G--' + ].include?(rtype) + use_min = [ # Situations in which to display MIN rather than MAX + rtype == 'average_rank', + board == 'sr' && rtype == 'score', + rtype == 'G--' + ].any? + fullB = format_full(nav ? false : full) + cool = format_cool(cool) + maxed = format_maxed(maxed) + maxable = format_maxable(maxable) + tabs = format_tabs(tabs) + typeB = format_type(type, true).downcase + range = no_range ? '' : format_range(range[0], range[1]) + star = format_star(star, long: true) + dev = format_dev(dev) + frac = format_frac(frac) + rtypeB = format_rtype(rtype, range: false, basic: true) + max = max ? format_max(max, use_min, bd: false) + '. ' : '' + board = !mappack.nil? && !no_board ? format_board(board) : '' + mappack = format_mappack(mappack) + play = !play.empty? ? ' without ' + play.map{ |p| "#{verbatim(p.print_name)}" }.to_sentence : '' + header = "#{fullB} #{cool} #{maxed} #{maxable} #{dev} #{frac} #{board} #{tabs} #{typeB} #{range} #{rtypeB} s" + header.sub!(/\s+s$/, 's') + header << " #{format_ties(ties)} #{mappack} #{play}" + header = mdtext("Rankings - #{format_header(header, close: '')}", header: 2) + footer = mdtext("__Date__: #{format_time(long: false, prep: false)}. #{max}#{min}", header: -1) + #header += "\n" + footer + # --- Rankings + if rank.empty? + rank = 'These boards are empty!' + count = 0 + else + rank = rank[pag[:offset]...pag[:offset] + pagesize] if !full || nav + count = rank.size + pad1 = rank.map{ |r| r[1].to_i.to_s.length }.max + pad2 = rank.map{ |r| r[0].length }.max + pad3 = rank.map{ |r| r[2].to_i.to_s.length }.max + fmt = rank[0][1].is_a?(Integer) ? "%#{pad1}d" : "%#{pad1 + 4}.3f" + rank = rank.each_with_index.map{ |r, i| + rankf = Highscoreable.format_rank(pag[:offset] + i) + rankf = ANSI.red + rankf + ANSI.reset if RICH_RANKINGS && count <= 20 + namef = format_string(r[0], pad2) + namef = ANSI.blue + namef + ANSI.reset if RICH_RANKINGS && count <= 20 + scoref = fmt % r[1] + scoref = ANSI.green + scoref + ANSI.reset if RICH_RANKINGS && count <= 20 + line = "#{rankf}: #{namef} - #{scoref}" + line += " (%#{pad3}d)" % [r[2]] if !r[2].nil? + line + }.join("\n") + end + + # SEND message + if nav + view = Discordrb::Webhooks::View.new + interaction_add_button_navigation(view, pag[:page], pag[:pages]) + interaction_add_type_buttons(view, type, ties) + interaction_add_select_menu_rtype(view, 'rank', rtype) + interaction_add_select_menu_metanet_tab(view, 'rank', tab) + send_message(event, content: header + "\n" + format_block(rank), components: view) + else + event << header + event << footer + count <= 20 ? event << format_block(rank) : send_file(event, rank, 'rankings.txt') + + end +rescue => e + lex(e, 'Failed to perform the rankings.', event: event) + nil +end + +# Sort highscoreables by amount of scores (0-20) with certain characteristics +# (e.g. classify levels by amount of cool/* scores) +def send_tally(event) + # Parse message parameters + msg = parse_message(event) + type = parse_type(msg) + tabs = parse_tabs(msg) + cool = parse_cool(msg) + star = parse_star(msg) + ties = parse_ties(msg) + range = parse_range(msg, true) + list = !!msg[/\blist\b/i] + + # Retrieve tally + res = Score.tally(list, type, tabs, ties, cool, star, range[0], range[1]) + count = list ? res.map(&:size).sum : res.sum + + # Format response + type = format_type(type) + tabs = format_tabs(tabs) + cool = format_cool(cool) + star = format_star(star) + ties = ties ? 'tied for 0th' : '' + pad1 = (0..20).select{ |r| list ? !res[r].empty? : res[r] > 0 }.max.to_s.length + pad2 = res.max.to_s.length if !list + block = (0..20).to_a.reverse.map{ |r| + if list + "#{r} #{cplural('score', r)}:\n\n" + res[r].join("\n") + "\n" if !res[r].empty? + else + "#{"%#{pad1}d #{cplural('score', r, true)}: %#{pad2}d" % [r, res[r]]}" if res[r] != 0 + end + }.compact.join("\n") + range = format_range(range[0], range[1]) + + # Send response + event << format_header("#{tabs} #{type} #{cool} #{range}#{star} scores #{ties} tally #{format_time}") + !list || count <= 20 ? event << format_block(block) : send_file(event, block, 'tally.txt') +rescue => e + lex(e, 'Error performing tally.', event: event) +end + +# Return a player's total score (sum of scores) in specified tabs and type +def send_total_score(event) + # Parse message parameters + msg = parse_message(event) + player = parse_player(event) + type = parse_type(msg, default: Level) + tabs = parse_tabs(msg) + + # Retrieve total score + score = player.total_score(type, tabs) + + # Format response + max = round_score(find_max(:score, type, tabs)) + type = format_type(type).downcase + tabs = format_tabs(tabs) + event << "#{player.print_name}'s total #{tabs} #{type} score is #{"%.3f" % [round_score(score)]} out of #{"%.3f" % max}.".squish +rescue => e + lex(e, "Error calculating total score.", event: event) +end + +# Return list of levels/episodes with largest/smallest score difference between +# 0th and Nth rank +def send_spreads(event) + # Parse message parameters + msg = parse_message(event) + n = (parse_rank(msg) || 2) - 1 + type = parse_type(msg, default: Level) + tabs = parse_tabs(msg) + player = parse_player(event, false, true, false) + full = parse_full(msg) + mappack = parse_mappack(event: event) + board = parse_board(msg, 'hs') + frac = parse_frac(msg, mappack, board) + small = !!(msg =~ /smallest/) + perror("Metanet maps only have highscore mode for now.") if !mappack && board != 'hs' + perror("This function is only available for highscore and speedrun modes for now.") if !['hs', 'sr'].include?(board) + perror("I can't show you the spread between 0th and 0th...") if n == 0 + + # Retrieve and format spreads + sr = board == 'sr' + spreads = Highscoreable.spreads(n, type, tabs, small, player.nil? ? nil : player.id, full, mappack, board, frac) + namepad = spreads.map{ |s| s[0].length }.max + scorepad = spreads.map{ |s| s[1] }.max.to_i.to_s.length + (sr ? (frac ? 4 : 0) : (frac ? 7 : 4)) + fmt = sr ? (frac ? '.3f' : 'd') : (frac ? '.6f' : '.3f') + spreads = spreads.each_with_index + .map { |s, i| "#{"%-#{namepad}s" % s[0]} - #{"%#{scorepad}#{fmt}" % s[1]} - #{s[2]}"} + .join("\n") + + # Format response + spread = small ? 'smallest' : 'largest' + rank = n.ordinalize + type = format_type(type).downcase.pluralize + tabs = tabs.empty? ? 'All ' : format_tabs(tabs) + player = !player.nil? ? "owned by #{player.print_name} " : '' + mappack = format_mappack(mappack) + board = format_board(board) + frac = format_frac(frac) + event << format_header("#{tabs} #{type} #{player} with the #{spread} #{frac} #{board} spread between 0th and #{rank} #{mappack}") + full ? send_file(event, spreads, 'spreads.txt') : event << format_block(spreads) +rescue => e + lex(e, "Error performing spreads.", event: event) +end + +# Send leaderboard for a highscoreable. Optionally add navigation widgets or a +# thumbnail. Can show interpolated scores, cheated scores, different boards... +def send_scores(event, offset: nil, date_change: nil) + # Parse message parameters + msg = parse_message(event) + initial = parse_initial(event) + hash = parse_palette(event) + msg = hash[:msg] + palette = hash[:palette] + h = parse_highscoreable(event, mappack: true) + offline = parse_offline(msg) + mappack = h.is_mappack? + board = parse_board(msg, 'hs', dual: true) + board = 'hs' if !mappack && board == 'dual' + cheated = !!msg[/\bwith\s+(nubs)|(cheaters)\b/i] && h.is_vanilla? + frac = parse_frac(msg, mappack ? h&.mappack : nil, board) && h.is_level? + ss = msg =~ /screenscores/i || msg =~ /shotscores/i || msg =~ /scoreshot/i || msg =~ /scorescreen/i + nav = parse_nav(msg) && !ss && ['hs', 'sr'].include?(board) + full = parse_full(msg) && h.is_mappack? && !nav && !ss + use_v2 = SCORE_THUMBNAILS && !nav + use_embed = (SCORE_EMBEDS && !msg[/\bmobile\b/i] || ss) && !use_v2 + date = parse_date(msg) + date = [date, Archive::EPOCH].max if date && !h.is_mappack? + + # Integrity checks + perror("Sorry, Metanet levels only support highscore mode.") if !mappack && board != 'hs' + + # Initialize final message elements + header = '' + body = '' + footer = '' + files = [] + embed = nil + view = Discordrb::Webhooks::View.new + + # If navigating, retrieve highscoreable and date + if nav + h = h.nav(offset.to_i) + dates = h.changes(board).map(&:to_i).sort.reverse + if initial || !date_change + new_index = 0 + else + old_date = event.message.components[1].to_a[2].custom_id.to_s.split(':').last.to_i + new_index = (dates.index{ |d| d == old_date } + date_change.to_i).clamp(0, dates.size - 1) + end + date = Time.at(dates[new_index]) + end + + # Update scores, unless we're in offline mode or the connection fails + if OFFLINE_STRICT + header << "Strict offline mode is ON, sending local cached scores.\n" + elsif !nav && !offline && h.is_a?(Downloadable) && !h.update_scores(fast: true) + header << "Connection to the server failed, sending local cached scores.\n" + end + + # If it's an episode, update all 5 level scores in the background + if h.is_a?(Episode) && !offline && !OFFLINE_STRICT + _thread(release: true) do + h.levels.each(&:update_scores) + end + end + + # Format scores + scores = h.format_scores(mode: board, full: full, join: false, cheated: cheated, frac: frac, date: date) + export = full && scores.count > 20 + use_embed = false if export + use_v2 = false if export + name = use_embed ? h.name : h.format_name + args = [ + nav ? 'navigating ' : '', + format_full(full), format_frac(frac), format_board(board).pluralize, + name, format_cheaters(cheated), format_date(date) + ] + header = format_header("%s %s %s %s for %s %s %s" % args) + header << "\n" if export + body = export ? '' : format_block(scores.join("\n")) + files << tmp_file(scores.join("\n"), "#{h.name}-scores.txt") if export + + # Generate thumbnail if necessary + if use_embed || use_v2 + thumb = Map.screenshot(palette, h: h, file: true, scale: use_v2 ? THUMBNAIL_SCALE : nil) + fn = File.basename(thumb.path) if thumb + files << thumb if thumb + end + + # Format body + if use_embed + color = Map::PALETTE[2, Palette::NAMES.index(palette)] >> 8 + embed = Discordrb::Webhooks::Embed.new( + title: header[..-2], + description: export ? nil : body, + color: color, + image: thumb ? Discordrb::Webhooks::EmbedImage.new(url: "attachment://#{fn}") : nil + ) + end + + # Add completion count, if available + if h.completions && h.completions > 0 + footer << "Scores: **#{h.completions}**. " + embed.add_field(name: 'Scores', value: h.completions.to_s, inline: true) if use_embed + end + + # Add cleanliness if it's an episode or a story + if show_cleanliness = (h.is_episode? || h.is_story?) && board != 'gm' + clean = send_clean_one(event, true) + footer << " Cleanliness: **#{clean}**." + embed.add_field(name: 'Cleanliness', value: clean, inline: true) if use_embed + end + + # Additional embed-exclusive fields + if use_embed + embed.add_field(name: 'Palette', value: palette, inline: true) + embed.add_field(name: 'Date', value: Time.now.strftime('%Y/%b/%d %H:%M'), inline: true) unless show_cleanliness + embed.add_field(name: 'Full name', value: h.format_name) if h.is_level? + end + + # Components + if use_v2 + view_add_text(content: '## 📊 ' + header, view: view) + view_add_section(texts: [body, footer], thumbnail: thumb, description: "Text desc", view: view) + view_add_file(file: files.first, view: view) if export + end + if nav + interaction_add_level_navigation(view, h.name.center(11, ' ')) + interaction_add_date_navigation(view, new_index + 1, dates.size, date.to_i, date.strftime("%Y-%b-%d")) + end + + # Send response + content = !use_embed && !use_v2 ? header + body + footer : '' + send_message(event, content: content, files: files, components: view, embeds: [embed], v2: use_v2) +rescue => e + lex(e, "Error sending scores.", event: event) +end + +# Send a screenshot of a level/episode/story +def send_screenshot(event, map = nil, ret: false, name: nil, palette: nil, mappack: nil, **kwargs) + # Parse message parameters + msg = parse_message(event) + hash = parse_palette(event, msg: palette) + msg = hash[:msg] + palette = hash[:palette] + pack = mappack ? parse_mappack(mappack, explicit: true, vanilla: false) : true + perror("No mappack found for #{verbatim(mappack)}") if !pack + h = map.nil? ? parse_highscoreable(event, mappack: pack, msg: name) : map + version = msg[/v(\d+)/i, 1] + channel = event.channel + spoiler = parse_spoiler(msg, h, channel) + + # Retrieve screenshot + h = h.map + max_v = h.version + version = version ? [max_v, [1, version.to_i].max].min : max_v + bench(:start) if BENCH_IMAGES + screenshot = Map.screenshot(palette, file: true, h: h, spoiler: spoiler, v: version) + perror("Failed to generate screenshot!") if screenshot.nil? + + # Send response + v_str = h.is_mappack? ? " v#{version}" : '' + str = "#{hash[:error]}Screenshot for #{h.format_name}#{v_str} in palette #{verbatim(palette)}:" + return [screenshot, str, spoiler] if ret + send_message(event, content: str, files: [screenshot], spoiler: spoiler) +rescue => e + lex(e, "Error sending screenshot.", event: event) +end + +# Send a thumbnail (files cannot be ephemeral so this is broken) +def send_thumbnail(event, id) + h = parse_highscoreable_by_id(id, mappack: true, silent: true)[1] + perror("Level not found", ephemeral: true) if h.empty? + screenshot = h[0].screenshot(file: true, scale: THUMBNAIL_SCALE) + perror("Failed to generate thumbnail", ephemeral: true) if !screenshot + send_message(event, files: [screenshot], ephemeral: true) +end + +# Returns rank distribution of a player's scores, in both table and histogram form +def send_stats(event) + # Parse message parameters + msg = parse_message(event) + player = parse_player(event) + tabs = parse_tabs(msg) + ties = parse_ties(msg) + mappack = parse_mappack(event: event) + board = parse_board(msg, 'hs') + + # Integrity checks + perror("Metanet boards only support highscore mode") if !mappack && board != 'hs' + perror("Stats can only be performed for highscore or speedrun mode") if !['hs', 'sr'].include?(board) + + # Retrieve counts and generate table and histogram + counts = player.score_counts(tabs, ties, mappack, board) + full_counts = (0..19).map{ |r| + l = counts[:levels][r].to_i + e = counts[:episodes][r].to_i + s = counts[:stories][r].to_i + [l + e + s, l + e, l, e, s] + } + totals = full_counts.reduce([0] * 5) { |sums, curr| sums.zip(curr).map(&:sum) } + maxes = [Level, Episode, Story].map{ |t| find_max(:rank, t, tabs, false, mappack, board) } + maxes.prepend(maxes.sum, maxes[0,2].sum) + percents = totals.map.with_index{ |tot, i| maxes[i] == 0 ? 0 : (100.0 * tot / maxes[i]).round } + histogram = make_histogram(full_counts.map(&:first).each_with_index.to_a.map(&:reverse)) + + # Format response + header = "Player #{format_tabs(tabs)} #{format_board(board)} counts #{format_mappack(mappack)} for #{player.print_name}" + widths = [5, 4, 4, 4, 4] + pads_d = widths.map{ |w| "%#{w}d" }.join(' ') + pads_s = widths.map{ |w| "%#{w}s" }.join(' ') + pads_p = widths.map{ |w| "%#{w - 1}d%%" }.join(' ') + counts = full_counts.each_with_index.map{ |c, r| "#{Highscoreable.format_rank(r)} │ #{pads_d % c}" }.join("\n ") + tot = "Tot │ #{pads_d}" % totals + max = "Max │ #{pads_d}" % maxes + per = " %% │ #{pads_p}" % percents + stats = "#{ANSI.bold} #{pads_s}#{ANSI.clear}\n" % ['Total', 'Solo', 'Lvl', 'Ep', 'Col'] + sep1 = '─' * 4 + '┬' + '─' * (tot.size - 5) + sep2 = '─' * 4 + '┼' + '─' * (tot.size - 5) + stats << sep1 << "\n " << counts << "\n" << sep2 << "\n" << tot << "\n" << max << "\n" << per + + send_message(event, content: format_header(header) + format_block(stats)) + sleep(0.25) + send_message(event, content: format_block(histogram)) +rescue => e + lex(e, "Error computing stats.", event: event) +end + +# Returns community's overal total and average scores +# * The total score is the sum of all 0th scores +# * The average score is the total score over the number of scores +# * The difference between level and episode scores is computed by adding +# the 5 corresponding level 0ths, subtracting the 4 * 90 additional +# seconds one gets at the start of each individual level (bar level 0), +# and then subtracting the episode 0th score. +def send_community(event) + # Parse message parameters + msg = parse_message(event) + tabs = parse_tabs(msg) + mappack = parse_mappack(event: event) + board = parse_board(msg, 'hs') + frac = parse_frac(msg, mappack, board) + has_secrets = !(tabs & TABS_SECRET).empty? || tabs.empty? + has_episodes = !(tabs - TABS_SECRET).empty? || tabs.empty? + + # Integrity checks + perror("Metanet boards only support highscore mode") if !mappack && board != 'hs' + perror("Stats can only be performed for highscore or speedrun mode") if !['hs', 'sr'].include?(board) + + # Retrieve community's total and average scores + levels = Scorish.total_scores(Level, tabs, true, true, mappack, board, frac) + if has_episodes + episodes = Scorish.total_scores(Episode, tabs, true, true, mappack, board, frac) + levels_no_secrets = has_secrets ? Scorish.total_scores(Level, tabs, false, true, mappack, board, frac) : levels + ep_difference = levels_no_secrets[0] - episodes[0] + ep_difference -= 4 * 90 * episodes[1] if board == 'hs' + ep_difference *= -1 if board == 'sr' + + stories = Scorish.total_scores(Story, tabs, true, true, mappack, board, frac) + levels_no_secrets_no_x = Scorish.total_scores(Level, tabs, false, false, mappack, board, frac) + sty_difference = levels_no_secrets_no_x[0] - stories[0] + sty_difference -= 24 * 90 * stories[1] if board == 'hs' + sty_difference *= -1 if board == 'sr' + end + + # Format scores + rows = [] + rows << ['', 'Total', 'Average'] + rows << :sep + rows << ['Level scores', levels[0], levels[0].to_f / levels[1] ] + rows << ['Episode scores', episodes[0], episodes[0].to_f / episodes[1]] if has_episodes + rows << ['Story scores', stories[0], stories[0].to_f / stories[1] ] if has_episodes + rows << :sep if has_episodes + rows << ['Episode difference', ep_difference, ep_difference.to_f / episodes[1]] if has_episodes + rows << ['Story difference', sty_difference, sty_difference.to_f / stories[1] ] if has_episodes + + # Format response + board = mappack ? format_board(board) : '' + mappack = format_mappack(mappack) + tabs = format_tabs(tabs) + header = "Community's total #{board} #{tabs} scores #{mappack} #{format_time}" + event << format_header(header) + "\n" + format_block(make_table(rows)) +rescue => e + lex(e, "Error computing community total scores.", event: event) +end + +# Return list of levels/episodes sorted by number of ties for 0th (desc) +def send_maxable(event, maxed = false) + # Parse message parameters + msg = parse_message(event) + player = parse_player(event, false, !msg[/missing/i], false) + type = parse_type(msg, default: Level) + tabs = parse_tabs(msg) + full = parse_full(msg) + mappack = parse_mappack(event: event) + board = parse_board(msg, 'hs') + perror("Metanet maps only have highscore mode for now.") if !mappack && board != 'hs' + perror("This function is only available for highscore and speedrun modes for now.") if !['hs', 'sr'].include?(board) + + # Retrieve maxed/maxable scores + ties = Highscoreable.ties(type, tabs, player.nil? ? nil : player.id, maxed, false, mappack, board) + ties = ties.take(NUM_ENTRIES) if (!maxed || mappack) && !full + pad1 = ties.map{ |s| s[0].length }.max + pad2 = ties.map{ |s| s[1].to_s.length }.max + count = ties.size + ties = ties.map { |s| + if maxed && !mappack + "#{"%-#{pad1}s" % s[0]} - #{format_string(s[2])}" + else + "#{"%-#{pad1}s" % s[0]} - #{"%#{pad2}d" % s[1]} - #{format_string(s[2])}" + end + }.join("\n") + + # Format response + type = format_type(type).downcase + tabs = format_tabs(tabs) + mappack = format_mappack(mappack) + board = format_board(board).pluralize + player = player.nil? ? '' : 'without ' + player.print_name + if maxed + event << format_header("There are #{count} #{tabs} potentially maxed #{type} #{board} #{mappack} #{format_time} #{player}") + else + event << format_header("#{tabs} #{type} #{board} with the most ties for 0th #{mappack} #{format_time} #{player}") + end + count <= NUM_ENTRIES ? event << format_block(ties) : send_file(event, ties, "maxed-#{tabs}-#{type}.txt") +rescue => e + lex(e, "Error computing maxables / maxes.", event: event) +end + +# Returns a list of maxed levels/episodes, i.e., with 20 ties for 0th +def send_maxed(event) + send_maxable(event, true) +end + +# Returns a list of episodes sorted by difference between +# episode 0th and the sum of the level 0ths +def send_cleanliness(event) + # Parse message parameters + msg = parse_message(event) + type = parse_type(msg, default: Episode) + tabs = parse_tabs(msg) + rank = parse_range(msg)[0] + board = parse_board(msg, 'hs') + mappack = parse_mappack(event: event) + full = parse_full(msg) + clean = !!msg[/cleanest/i] + perror("Cleanliness is only available for episodes or stories.") if type == Level + perror("Cleanliness is only supported for highscore or speedrun mode.") if !['hs', 'sr'].include?(board) + perror("Metanet only supports highscore mode for now.") if mappack.nil? && board != 'hs' + + # Retrieve episodes and cleanliness + list = Highscoreable.cleanliness(type, tabs, rank, mappack, board) + .sort_by{ |e| (clean ? e[1] : -e[1]) } + list = list.take(NUM_ENTRIES) if !full + size = list.size + fmt = list[0][1].is_a?(Integer) ? 'd' : '.3f' + pad1 = list.map{ |e| e[0].length }.max + pad2 = list.map{ |e| e[1].to_i.to_s.length + (fmt == 'd' ? 0 : 4) }.max + list = list.map{ |e| "#{"%#{pad1}s" % e[0]} - #{"%#{pad2}#{fmt}" % e[1]} - #{e[2]}" }.join("\n") + + # Format response + code = mappack ? "_#{mappack.code}" : '' + file = "#{clean}_#{board}#{code}_#{format_type(type)}.txt" + tabs = tabs.empty? ? 'All ' : format_tabs(tabs) + clean = clean ? 'cleanest' : 'dirtiest' + board = format_board(board) + mappack = format_mappack(mappack) + header = "#{tabs} #{clean} #{board} episodes #{mappack} #{format_time}:".squish + + # Send response + event << header + size > NUM_ENTRIES ? send_file(event, list, file) : event << format_block(list) +rescue => e + lex(e, "Error computing cleanlinesses.", event: event) +end + +# Returns the cleanliness of a single episode or story 0th +def send_clean_one(event, ret = false) + # Parse params + msg = parse_message(event) + h = parse_highscoreable(event, mappack: true) + perror("Cleanliness is an episode/story-specific function!") if h.is_level? + board = parse_board(msg, 'hs') + perror("Sorry, G-- cleanlinesses aren't available yet.") if board == 'gm' + perror("Only highscore mode is available for Metanet levels for now.") if !h.is_mappack? && board != 'hs' + rank = !ret ? parse_range(msg)[0] : 0 + + # Compute cleanliness + clean = h.cleanliness(rank, board) + (ret ? (return '') : perror("No #{rank.ordinalize} #{format_board(board)} score found in this leaderboard.")) if !clean + clean_round = round_score(clean) + fmt = clean.is_a?(Integer) ? '%df' : '%.3f (%df)' + args = clean.is_a?(Integer) ? [clean_round] : [clean_round, (60 * clean_round).round] + return fmt % args if ret + + # Compute extra info for the dedicated function + event << "The cleanliness of #{h.name}'s #{format_board(board)} #{rank.ordinalize} is #{fmt % args}." + + clean_round = clean_round.to_f / 5 + fmt = clean.is_a?(Integer) ? '%.1ff' : '%.3f (%.1ff)' + args = clean.is_a?(Integer) ? [clean_round] : [clean_round, 60 * clean_round] + event << "Average per-#{h.is_episode? ? 'level' : 'episode'} cleanliness of #{fmt % args}." + + if h.is_story? + clean_round = clean_round.to_f / 5 + fmt = clean.is_a?(Integer) ? '%.1ff' : '%.3f (%.1ff)' + args = clean.is_a?(Integer) ? [clean_round] : [clean_round, 60 * clean_round] + event << "Average per-level cleanliness of #{fmt % args}." + end +rescue => e + lex(e, "Error computing cleanliness.", event: event) +end + +# Returns a list of episode ownages, i.e., episodes where the same player +# has 0th in all 5 levels and the episode +def send_ownages(event) + # Parse message parameters + msg = parse_message(event) + tabs = parse_tabs(msg) + + # Retrieve ownages + ownages = Episode.ownages(tabs) + pad = ownages.map{ |e, p| e.length }.max + list = ownages.map{ |e, p| "#{"%#{pad}s" % e} - #{p}" }.join("\n") + count = ownages.count + if count <= 20 + block = list + else + block = ownages.group_by{ |e, p| p }.map{ |p, o| "#{format_string(p)} - #{o.count}" }.join("\n") + end + + # Format response + tabs_h = tabs.empty? ? 'All ' : format_tabs(tabs) + tabs_f = tabs.empty? ? '' : format_tabs(tabs) + event << "#{tabs_h} episode ownages #{format_max(find_max(:rank, Episode, tabs))} #{format_time}:".squish + event << format_block(block) + "There're a total of #{count} #{tabs_f} episode ownages." + send_file(event, list, 'ownages.txt') if count > 20 +rescue => e + lex(e, "Error computing ownages.", event: event) +end + +# Return list of a player's most improvable scores, filtered by type and tab +def send_worst(event, worst = true) + # Parse message parameters + msg = parse_message(event) + player = parse_player(event) + type = parse_type(msg, default: Level) + tabs = parse_tabs(msg) + full = parse_full(msg) + mappack = parse_mappack(event: event) + board = parse_board(msg, 'hs') + frac = parse_frac(msg, mappack, board) + agd = !!msg[/\bagd?\b/i] || !!msg[/\bg\+\+(\s|$)/i] + perror("Metanet levels only support highscore mode.") if (board != 'hs' || agd) && !mappack + + # Retrieve and format most improvable scores + if agd || board == 'gm' + list = player.gold_gaps(type, tabs, worst, full, mappack, agd) + else + list = player.score_gaps(type, tabs, worst, full, mappack, board, frac) + end + board = 'gp' if agd + fmt = board == 'hs' || frac ? '.3f' : 'd' + pad1 = list.map{ |level, gap| level.length }.max + pad2 = list.map{ |level, gap| gap }.max.to_i.to_s.length + (fmt == 'd' ? 0 : 4) + list = list.map{ |level, gap| "%-*s - %*#{fmt}" % [pad1, level, pad2, frac ? gap : round_score(gap)] }.join("\n") + + # Send response + adverb = worst ? 'most' : 'least' + worst = worst ? 'worst' : 'best' + tabs = format_tabs(tabs) + type = format_type(type).downcase + mappack = format_mappack(mappack) + board = format_board(board).pluralize + frac = format_frac(frac) + event << format_header("#{adverb} improvable #{frac} #{tabs} #{type} #{board} #{mappack} for #{player.print_name}") + full ? send_file(event, list, "#{worst}.txt") : event << format_block(list) +rescue => e + lex(e, "Error getting worst scores.", event: event) +end + +# Return level ID for a specified level name +def send_level_id(event) + level = parse_highscoreable(event, mappack: true) + perror("Episodes and stories don't have a name!") if level.is_episode? || level.is_story? + event << "#{level.longname} is level #{level.name}." +rescue => e + lex(e, "Error getting ID.", event: event) +end + +# Return level name for a specified level ID +def send_level_name(event) + level = parse_highscoreable(event, mappack: true) + perror("Episodes and stories don't have a name!") if level.is_episode? || level.is_story? + event << "#{level.name} is called #{level.longname}." +rescue => e + lex(e, "Error getting name.", event: event) +end + +# Return a player's point count +# (a 0th is worth 20 points, a 1st is 19 points, all the way down to +# 1 point for a 19th score) +# Arguments: +# 'avg' we compute the average points, see method below +# 'rank' we compute the average rank, which is just 20 - avg points +def send_points(event, avg = false, rank = false) + # Parse message parameters + msg = parse_message(event) + player = parse_player(event) + type = parse_type(msg) + tabs = parse_tabs(msg) + + # Retrieve player points, filtered by type and tabs + points = avg ? player.average_points(type, tabs) : player.points(type, tabs) + + # Format and send response + max = find_max(avg ? :avg_points : :points, type, tabs) + type = format_type(type).downcase + tabs = format_tabs(tabs) + if avg + if rank + event << "#{player.print_name} has an average #{tabs} #{type} rank of #{"%.3f" % [20 - points]}.".squish + else + event << "#{player.print_name} has #{"%.3f" % [points]} out of #{"%.3f" % max} average #{tabs} #{type} points.".squish + end + else + event << "#{player.print_name} has #{points} out of #{max} #{tabs} #{type} points.".squish + end +rescue => e + lex(e, "Error computing points.", event: event) +end + +# Return a player's average point count +# (i.e., total points divided by the number of scores, measures score quality) +def send_average_points(event) + send_points(event, true) +rescue => e + lex(e, "Error computing average points.", event: event) +end + +# Return a player's average rank across all their scores, ideal quality measure +# It's actually just 20 - average points +def send_average_rank(event) + send_points(event, true, true) +rescue => e + lex(e, "Error computing average rank.", event: event) +end + +# Return a player's average 0th lead across all their 0ths +def send_average_lead(event) + # Parse message parameters + msg = parse_message(event) + player = parse_player(event) + type = parse_type(msg, default: Level) + tabs = parse_tabs(msg) + + # Retrieve average 0th lead + average = player.average_lead(type, tabs) + + # Format and send response + type = format_type(type).downcase + tabs = format_tabs(tabs) + event << "#{player.print_name} has an average #{type} #{tabs} lead of #{"%.3f" % [average]}.".squish +rescue => e + lex(e, "Error computing average lead.", event: event) +end + +# Return a table containing a certain measure (e.g. top20 count, points, etc) +# and classifying it by type (columns) and tabs (rows) +def send_table(event) + # Parse message parameters + msg = parse_message(event) + player = parse_player(event) + cool = parse_cool(msg) + star = parse_star(msg) + global = false # Table for a user, or the community + ties = parse_ties(msg) + mappack = parse_mappack(event: event) + board = parse_board(msg, 'hs') + frac = parse_frac(msg, mappack, board) + avg = !!(msg =~ /\baverage\b/i) || !!(msg =~ /\bavg\b/i) + + # Integrity checks + perror("Metanet boards only support highscore mode") if !mappack && board != 'hs' + perror("The table can only be crafted for highscore or speedrun mode") if !['hs', 'sr'].include?(board) + + # Parse table type + if avg + if msg =~ /\bpoint\b/i + rtype = :avg_points + header = "average points" + else + rtype = :avg_rank + header = "average rank" + end + elsif msg =~ /\bpoint/i + rtype = :points + header = "points" + elsif msg =~ /\bscore/i + rtype = :score + header = "total scores" + elsif msg =~ /\btied\b/i + rtype = :tied_rank + header = "tied scores" + elsif msg =~ /\bmaxed/i + rtype = :maxed + header = "maxed scores" + global = true + elsif msg =~ /\bmaxable/i + rtype = :maxable + header = "maxable scores" + global = true + else + rtype = :rank + header = "scores" + end + range = parse_range(msg, cool || star || ![:rank, :tied_rank].include?(rtype), rtype == :score) + frac &&= rtype == :score || mappack&.fractional + + # The range must make sense + if !range[2] + event << "You specified an empty range! (#{format_range(range[0], range[1])})" + return + end + + # Retrieve table (a matrix, first index is type, second index is tab) + table = player.table(rtype, ties, range[0], range[1], cool, star, mappack, board, frac) + + # Construct table. If it's an average measure, we need to retrieve the + # table of totals first to do the weighed averages. + if avg + scores = player.table(:rank, ties, 0, 20) + totals = Level::tabs.select{ |tab, id| id < 7 }.map{ |tab, id| + lvl = scores[0][tab] || 0 + ep = scores[1][tab] || 0 + col = scores[2][tab] || 0 + [format_tab(tab.to_sym), lvl, ep, col, lvl + ep, lvl + ep + col] + } + end + table = Level::tabs.select{ |tab, id| id < 7 }.each_with_index.map{ |tab, i| + lvl = table[0][tab[0]] || 0 + ep = table[1][tab[0]] || 0 + col = table[2][tab[0]] || 0 + [ + format_tab(tab[0].to_sym), + avg || frac ? lvl : round_score(lvl), + avg || frac ? ep : round_score(ep), + avg || frac ? col : round_score(col), + avg ? wavg([lvl, ep], totals[i][1..2]) : frac ? lvl + ep : round_score(lvl + ep), + avg ? wavg([lvl, ep, col], totals[i][1..3]) : frac ? lvl + ep + col : round_score(lvl + ep + col) + ] + } + + # Format table rows + rows = [] + rows << ["", "Level", "Episode", "Column", "Solo", "Total"] + rows << :sep + rows += table + rows << :sep + if !avg + rows << [ + "Total", + *5.times.map{ |i| table.map{ |t| t[i + 1] }.sum } + ] + else + rows << [ + "Total", + *5.times.map{ |i| wavg(table.map{ |t| t[i + 1] }, totals.map{ |t| t[i + 1] }) } + ] + end + + # Send response + cool = format_cool(cool) + star = format_star(star) + ties = format_ties(ties) + range = format_range(range[0], range[1], [:maxed, :maxable].include?(rtype)) + board = mappack ? format_board(board) : '' + mappack = format_mappack(mappack) + frac = format_frac(frac) + name = "#{frac} #{cool} #{board} #{range}#{star} #{header} #{ties}" + header = "#{name} table".squish + player = global ? '' : "#{player.format_name.strip}'s" + event << format_header("#{player} #{header} #{mappack} #{format_time}") + event << format_block(make_table(rows, name.squish.capitalize.pluralize)) +rescue => e + lex(e, "Error crafting table.", event: event) +end + +# Return score comparison between 2 players. Lists 5 categories: +# Scores which only P1 has +# Scores where P1 > P2 +# Scores where P1 = P2 +# Scores where P1 < P2 +# Scores which only P2 has +# Returns both the counts, as well as the list of scores in a file +def send_comparison(event) + # Parse message parameters + msg = parse_message(event) + type = parse_type(msg) + tabs = parse_tabs(msg) + p1, p2 = parse_players(event) + + # Retrieve comparison info + comp = Player.comparison(type, tabs, p1, p2) + counts = comp.map{ |t| t.map{ |r, s| s.size }.sum } + + # Format message + header = "#{format_type(type)} #{format_tabs(tabs)} comparison between #{p1.truncate_name} and #{p2.truncate_name} #{format_time}:".squish + rows = ["Scores with only #{p1.truncate_name}"] + rows << "Scores where #{p1.truncate_name} > #{p2.truncate_name}" + rows << "Scores where #{p1.truncate_name} = #{p2.truncate_name}" + rows << "Scores where #{p1.truncate_name} < #{p2.truncate_name}" + rows << "Scores with only #{p2.truncate_name}" + table = rows.zip(counts) + pad1 = table.map{ |row, count| row.length }.max + pad2 = table.map{ |row, count| numlen(count, false) }.max + table = table.map{ |r, c| "#{"%-#{pad1}s" % r} - #{"%#{pad2}d" % c}" }.join("\n") + + # Format file + list = (0..4).map{ |i| + pad1 = comp[i].map{ |r, s| s.map{ |e| e.size == 2 ? e[0][2].length : e[2].length }.max }.max + pad2 = comp[i].map{ |r, s| s.map{ |e| e.size == 2 ? numlen(e[0][3]) : numlen(e[3]) }.max }.max + pad3 = comp[i].map{ |r, s| s.map{ |e| e.size == 2 ? numlen(e[1][3]) : numlen(e[3]) }.max }.max + rows[i] + ":\n\n" + comp[i].map{ |r, s| + s.map{ |e| + if e.size == 2 + str = "#{"%-#{pad1}s" % e[0][2]} - " + str += "[#{"%02d" % e[0][0]}: #{"%#{pad2}.3f" % e[0][3]}] vs. " + str += "[#{"%02d" % e[1][0]}: #{"%#{pad3}.3f" % e[1][3]}]" + str + else + "#{"%02d" % e[0]}: #{"%-#{pad1}s" % e[2]} - #{"%#{pad2}.3f" % e[3]}" + end + }.join("\n") + }.join("\n") + "\n" + }.join("\n") + + # Send response + event << header + format_block(table) + send_file(event, list, "comparison-#{p1.sanitize_name}-#{p2.sanitize_name}.txt") +rescue => e + lex(e, "Error performing comparison.", event: event) +end + +# Return a list of random highscoreables +def send_random(event) + # Parse message parameters + msg = parse_message(event) + type = parse_type(msg, default: Level) + tabs = parse_tabs(msg) + amount = msg[/\d+/].to_i.clamp(1, NUM_ENTRIES) + mappack = parse_mappack(event: event) + + # Retrieve list of maps + type = type.mappack if mappack + maps = mappack ? type.where(mappack: mappack) : type + maps = tabs.empty? ? maps.all : maps.where(tab: tabs) + maps = maps.sample(amount) + + # Format and send response + if amount > 1 + tabs = format_tabs(tabs) + type = format_type(type).downcase.pluralize + mappack = format_mappack(mappack) + event << format_header("Random selection of #{amount} #{tabs} #{type} #{mappack}") + send_file(event, Map.screenshot(h: maps, titles: maps.map(&:name)), 'sample.png', true) + else + send_screenshot(event, maps.first) + end +rescue => e + lex(e, "Error getting random sample.", event: event) +end + +# Return list of challenges for specified level, ordered and formatted as in the game +def send_challenges(event) + return unless is_botmaster?(event) || Challengish.check_channel(event.channel) + h = parse_highscoreable(event, mappack: true) + perror("#{h.class.vanilla.to_s.pluralize.capitalize} don't have challenges!") if !h.is_level? + perror("#{MODES[h.mode].capitalize} doesn't have challenges!") if h.mode != MODE_SOLO + perror("#{h.tab.to_s} don't have challenges!") if ["SI", "SL"].include?(h.tab.to_s) + challenges = h.format_challenges + perror("This level has no challenges.") if challenges.empty? + content = "## Challenges for #{h.format_name}:\n#{format_block(challenges)}" + view = create_components() + if h.is_vanilla? && !h.videos.empty? + view.row{ |row| + row.button(label: 'Videos', custom_id: "VIDEOS:#{h.name}", emoji: '🎞️', style: :primary) + } + end + send_message(event, content: content, components: view) +rescue => e + lex(e, "Error getting challenges.", event: event) +end + +# Send the list of challenge videos for a given highscoreable +# TODO: G++ is currently defaulted to nil, but for episodes/stories it could make sense to keep it +def send_videos(event, highscoreable: nil, challenge_code: nil, streamer_code: nil, edit: nil) + return unless is_botmaster?(event) || Challengish.check_channel(event.channel) + h, challenge, streamer, video, view = nil + v2 = true + content = '' + + # Parse highscoreable + h = parse_highscoreable(event, msg: highscoreable) + perror("#{MODES[h.mode].capitalize} videos aren't supported yet") if h.mode != MODE_SOLO + videos = h.videos + perror("#{h.format_name} has no videos yet! :pensive:") if videos.empty? + + # Parse challenge + if challenge_code + q = Challengish.parse_code(challenge_code).merge(level_id: h.id) + count = q.count{ |k, v| k.length == 1 && v != 0 } + if count == 1 && q[:g] == 1 # Regular G++ isn't an actual challenge + q[:g] = 0 + count -= 1 + end + challenge = count > 0 ? Challenge.find_by(q) : nil + perror("#{h.name} has no #{Challengish.format_code(q)} challenge!") if count > 0 && !challenge + challenge_code = challenge ? challenge.format_objs : h.is_level? ? 'G++' : 'N++' + end + + # Parse streamer and video (TODO: pagination) + if streamer_code + streamer = Streamer.find_by(code: streamer_code) + streamer = Streamer.find_matches(:name, streamer_code) unless streamer + case streamer + when Streamer + video = Video.find_by(highscoreable: h, challenge: challenge, streamer: streamer) + perror("#{streamer.name} has no #{h.name} #{challenge_code} video.") if !video + when ActiveRecord::Relation + list = format_block(streamer.pluck(:name).join("\n")) + perror("Multiple players matching `#{streamer_code}` have videos, who did you mean?\n#{list}") + when Array + list = format_block(streamer.join("\n")) + perror("No players matching `#{streamer_code}` have videos. Did you mean...\n#{list}") + else + perror("Player #{streamer_code} has no published videos!") if !streamer + end + end + + if !challenge_code # Send challenge list + edit = true if edit.nil? + view = view_add_text(content: "## 🎞️ __Videos for #{h.name}__") + challenges = [nil] + (h.is_level? ? h.challenges : []) + videos = videos.group_by{ |v| v.challenge }.to_h + challenges.each{ |challenge| + count = videos[challenge]&.size.to_i + code = challenge ? challenge.format : h.is_level? ? '[] G++' : '[!?] N++' + text = "➤ `#{code}` (#{count} videos)" + view_add_section( + texts: [text], + button: 'View', + custom_id: "videos:#{h.name}:#{code.sub(/\[.*\]/, '').strip}", + style: :secondary, + disabled: count == 0, + view: view + ) + } + elsif !streamer_code # Send video list + edit = true if edit.nil? + view = view_add_text(content: "## 🎞️ __Videos for #{challenge_code} in #{h.name}__") + videos = Video.where(highscoreable: h, challenge: challenge).map{ |video| + emoji = case video.url + when /youtu/i + $emojis['youtube'] + when /twitch/i + $emojis['twitch'] + when /odysee/i + $emojis['odysee'] + end + { + label: "#{video.streamer.name} (#{video.streamer.code})", + id: "videos:#{h.name}:#{challenge_code}:#{video.streamer.code}", + emoji: emoji + } + } + if videos.empty? + view_add_text(content: "This challenge has no videos yet, be the first one to upload one!", view: view) + elsif videos.size <= 10 + videos.each{ |hash| + view_add_section( + texts: [hash[:label]], + button: 'Watch', + custom_id: hash[:id], + style: :secondary, + emoji: hash[:emoji], + view: view + ) + } + else + view.row{ |row| + row.string_select(custom_id: "videos:#{h.name}:#{challenge_code}", placeholder: 'Select video', max_values: 1){ |menu| + alert("#{videos.size - DISCORD_OPTIONS_LIMIT} videos skipped in #{h.name}") if videos.size > DISCORD_OPTIONS_LIMIT + videos.take(DISCORD_OPTIONS_LIMIT).each{ |hash| + menu.option(label: hash[:label], value: hash[:id][/:([^:]+)$/, 1], emoji: hash[:emoji]) + } + } + } + end + view.row{ |r| r.button(label: 'Back to level', custom_id: "videos:#{h.name}", style: :primary) } + else # Send embedded video + # The message must not be V2, since videos don't embed there, and thus it must be a new message. + content = "## 🎞️ [__Video by #{streamer.name} for #{challenge_code} in #{h.name}__](#{video.url})" + v2, edit = false, false + end + + send_message(event, content: content, components: view, v2: v2, edit: edit) +end + +# Return list of matches for specific level name query +# Also the fallback for other functions when there are multiple matches +# (e.g. scores, screenshot, challenges, level id, ...) +# 'page' parameters controls button page navigation when there are many results +def send_query(event, page: nil) + parse_highscoreable(event, list: true, mappack: true, page: page, type: Level) +rescue => e + lex(e, "Error performing query.", event: event) +end + +# Sends the Top20 changes in some leaderboard since the specified date, default to lotd +def send_diff(event) + msg = parse_message(event) + h = parse_highscoreable(event, mappack: true, silent: true) + type = h ? h.class.vanilla : parse_type(msg, default: Level) + board = parse_board(msg, 'dual', dual: true) + + # If no highscoreable has been explicitly provided, default to lotd/eotw/cotm + if !h || h.is_lotd? + # Ensure lotd exists + mappack = h && h.is_mappack? ? h.mappack : parse_mappack(event: event) + period = type == Level ? 'day' : type == Episode ? 'week' : 'month' + type_str = type == Level ? 'level' : type == Episode ? 'episode' : 'column' + code = mappack.nil? || mappack.id == 0 ? '' : mappack.code.upcase + ' ' + name = "#{code}#{type_str} of the #{period}" + perror("There is no #{name}.") if mappack && !mappack.lotd + + # Get highscoreable and date + ctp = !!mappack && mappack.code == 'ctp' + h = GlobalProperty.get_current(type, ctp) + perror("There is no current #{name}.") if h.nil? + default_date = GlobalProperty.get_saved_scores(type, ctp) + explicit = false + else + mappack = h.is_mappack? ? h.mappack : nil + default_date = h.is_level? ? 1.day.ago : h.is_episode? ? 1.week.ago : 1.month.ago + explicit = true + end + date = parse_date(msg) || default_date + date = [date, Archive::EPOCH].max if !h.is_mappack? + + # Integrity checks + perror("Speedrun diffs aren't available for Metanet.") if board == 'sr' && !mappack + perror("Gold diffs aren't available.") if ['gm', 'gp'].include?(board) + + # Fetch and format differences + str = h.format_difference_message(h.format_difference(date, board), date, explicit: explicit) + event << str +rescue => e + lex(e, "Error finding differences.", event: event) +end + +def send_mappacks(event, page: nil) + msg = parse_message(event) + short = !!msg[/short/i] + priv = !!msg[/private/i] && is_botmaster?(event) + klass = priv ? Mappack.all : Mappack.where(public: true) + count = klass.count + counts = MappackLevel.group(:mappack_id).count + page_size = 10 + page = parse_page(msg, page, false, event.message.components) + pag = compute_pages(count, page, page_size) + list = klass.order(date: :desc).offset(pag[:offset]).limit(page_size).map{ |m| + fields = [] + fields << m.code.upcase + (!m.enabled || !m.public ? '*' : '') + fields << m.name.to_s unless short + fields << m.authors.to_s unless short + fields << m.date.strftime('%Y/%b/%d') rescue '' + fields << counts[m.id].to_i + fields + } + header = [] + header << 'Code' + header << 'Name' unless short + header << 'Authors' unless short + header << 'Date' + header << 'Levels' + rows = [header, :sep, *list] + str = mdhdr1("📦 __Mappacks__\n") + "There are #{count} supported mappacks. Write their 3 letter code to use them in functions." + str << format_block(make_table(rows)) + components = interaction_add_button_navigation_short(nil, pag[:page], pag[:pages], 'send_mappacks', wrap: false) + send_message(event, content: str, components: components) +rescue => e + lex(e, 'Error sending mappack list.') +end + +# Return the demo analysis of a level's replay +def send_analysis(event) + # Parse message parameters + msg = parse_message(event) + ranks = parse_ranks(msg, -1) + board = parse_board(msg, 'hs') + h = parse_highscoreable(event, mappack: true) + frac = parse_frac(msg, h.is_mappack? ? h.mappack : nil, board) + + # Integrity checks + perror("Episodes and columns can't be analyzed yet.") if h.is_episode? || h.is_story? + perror("Metanet levels only support highscore mode for now.") if !h.is_mappack? && board != 'hs' + perror("G-- mode is not supported yet.") if board == 'gm' + perror("This analysis is disabled, figure it out yourself!") if h.is_protected? + + # Fetch runs + boards = h.leaderboard(board, truncate: 0, pluck: false, frac: frac).all + analysis = ranks.map{ |rank| [rank, (boards[rank].archive rescue nil)] }.to_h + missing = analysis.select{ |r, a| a.nil? }.keys + event << "-# __Warning__: #{'Run'.pluralize(missing.size)} with rank #{missing.to_sentence} not found." if !missing.empty? + analysis.reject!{ |r, a| a.nil? } + perror("No runs to analyze") if analysis.size == 0 + + # Get run elements + sfield = h.is_mappack? ? "score_#{board}" : 'score' + scale = board == 'hs' ? 60.0 : 1 + analysis = analysis.map{ |rank, run| + { + 'player' => run.player.name, + 'rank' => rank, + 'score' => run[sfield] / scale, + 'inputs' => run.demo.decode, + 'gold' => run.gold + } + } + missing = analysis.select{ |a| !a['inputs'] }.map{ |a| a['rank'] } + event << "-# __Warning__: #{'Replay'.pluralize(missing.size)} for rank #{missing.to_sentence} not found." if !missing.empty? + analysis.reject!{ |a| !a['inputs'] } + perror("No runs to analyze") if analysis.size == 0 + length = analysis.map{ |a| a['inputs'].size }.max + perror("The selected runs are empty.") if length == 0 + + # We format the result in 3 different ways, only 2 are being used though. + # Format 1 example: + # R.R.R.JR.JR... + raw_result = analysis.map{ |a| + a['inputs'].map{ |b| + [b % 2 == 1, b / 2 % 2 == 1, b / 4 % 2 == 1] + }.map{ |f| + (f[2] ? 'L' : '') + (f[1] ? 'R' : '') + (f[0] ? 'J' : '') + }.join(".") + }.join("\n\n") + + # Format 2 example: + # |JRL| + # --------- + # 0001| > | + # 0002| > | + # 0003| > | + # 0004|^> | + # 0005|^> | + # ... + padding = Math.log(length, 10).to_i + 1 + head = " " * padding + "|" + "LJR|" * analysis.size + sep = "-" * head.size + table_result = analysis.map{ |a| + table = a['inputs'].map{ |b| + [ + b / 4 % 2 == 1 ? "<" : " ", + b % 2 == 1 ? "^" : " ", + b / 2 % 2 == 1 ? ">" : " " + ].push("|") + } + while table.size < length do table.push([" ", " ", " ", "|"]) end + table.transpose + }.flatten(1) + .transpose + .each_with_index + .map{ |l, i| "%0#{padding}d|#{l.join}" % [i + 1] } + .insert(0, head) + .insert(1, sep) + .join("\n") + + # Format 3 example: + # >>>//... + codes = [ + ['-', 'Nothing' ], + ['^', 'Jump' ], + ['>', 'Right' ], + ['/', 'Right Jump' ], + ['<', 'Left' ], + ['\\', 'Left Jump' ], + ['≤', 'Left Right' ], + ['|', 'Left Right Jump'] + ] + key_result = analysis.map{ |a| + a['inputs'].map{ |f| + codes[f][0] || '?' rescue '?' + }.join + .scan(/.{,60}/) + .reject{ |f| f.empty? } + .each_with_index + .map{ |f, i| "%0#{padding}d #{f}" % [60 * i] } + .join("\n") + }.join("\n\n") + + # Format response + # - Digest of runs' properties (length, score, gold collected, etc) + sr = h.is_mappack? && board == 'sr' + gm = h.is_mappack? && board == 'gm' + fmt = analysis[0]['score'].is_a?(Integer) ? "%d" : "%.3f" + ppad = analysis.map{ |a| a['player'].length }.max + rpad = [analysis.map{ |a| a['rank'].to_s.length }.max, 2].max + spad = analysis.map{ |a| (fmt % a['score']).length }.max + fpad = analysis.map{ |a| a['inputs'].size }.max.to_s.length + gpad = analysis.map{ |a| a['gold'] }.max.to_s.length + properties = format_block( + analysis.map{ |a| + rank_text = a['rank'].to_s.rjust(rpad, '0') + name_text = format_string(a['player'], ppad) + score_text = (fmt % a['score']).rjust(spad) + frame_text = a['inputs'].size.to_s.rjust(fpad) + 'f, ' unless sr + gold_text = a['gold'].to_s.rjust(gpad) + 'g' unless gm + "#{rank_text}: #{name_text} - #{score_text} [#{frame_text}#{gold_text}]" + }.join("\n") + ) + # - Summary of symbols' meaning + explanation = "[#{codes.map{ |code, meaning| "**#{Regexp.escape(code)}** #{meaning}" }.join(', ')}]" + # - Header of message, and final result (format 2 only used if short enough) + header = "Replay analysis for #{h.format_name} #{format_time}.".squish + result = "#{header}\n#{properties}" + result += "#{explanation}#{format_block(key_result)}" unless analysis.sum{ |a| a['inputs'].size } > 1080 + + # Send response + event << result + send_file(event, table_result, "analysis-#{h.name}.txt") +rescue => e + lex(e, "Error performing demo analysis.", event: event) +end + +def send_demo_download(event, name: nil, mappack: nil, rank: nil, board: nil, **kwargs) + msg = parse_message(event) + pack = mappack ? parse_mappack(mappack, explicit: true, vanilla: false) : true + perror("No mappack found for #{verbatim(mappack)}") if !pack + h = parse_highscoreable(event, mappack: pack, msg: name) + perror("Downloading this replay is disabled, figure it out yourself!") if h.is_protected? + perror("Speedrun mode isn't supported for Metanet levels") if !h.is_mappack? && board == 'sr' + board ||= 'hs' + scores = h.leaderboard(board, truncate: 0, pluck: false) + perror("There are no scores for this #{h.class.vanilla.downcase}") if scores.empty? + rank ||= parse_range(msg).first + perror("The rank must be between 0 and #{scores.size - 1} for this board") if !rank.between?(0, scores.size - 1) + score = scores[rank] + scale = h.is_mappack? && board == 'hs' ? 60.0 : 1 + fscore = (board == 'hs' ? '%.3f' : '%df') % [h.is_mappack? ? score["score_#{board}"] / scale : score.score] + send_message( + event, + content: "Downloading #{score.player.name}'s #{rank.ordinalize} score in #{h.name} (#{fscore}):", + files: [tmp_file(score.demo.demo, "#{h.name}_#{rank.ordinalize}_replay", binary: true)] + ) +rescue => e + lex(e, "Error downloading demo.", event: event) +end + +def send_download(event, name: nil, mappack: nil, **kwargs) + pack = mappack ? parse_mappack(mappack, explicit: true, vanilla: false) : true + perror("No mappack found for #{verbatim(mappack)}") if !pack + h = parse_highscoreable(event, mappack: pack, msg: name, map: true) + perror("Only levels can be downloaded") if !h.is_level? + send_message( + event, + content: "Downloading #{h.format_name}:", + files: [tmp_file(h.dump_level, h.name, binary: true)] + ) +rescue => e + lex(e, "Error preparing downloading.", event: event) +end + +# Trace or animate a level / episode run given the map data and demo data using +# SimVYo's nsim tool to simulate the physics. +def send_trace(event) + perror("Sorry, NSim is disabled.") if !FEATURE_NTRACE + msg = parse_message(event) + wait_msg = send_message(event, content: "Queued...", db: false) if $mutex[:trace].locked? + $mutex[:trace].synchronize do + wait_msg.delete if !wait_msg.nil? rescue nil + Map.trace(event, anim: !!msg[/anim/i]) + end +rescue => e + lex(e, "Error performing trace.", event: event) +end + +# Return an episode's partial level scores and splits using 2 methods: +# 1) The actual episode splits, using SimVYo's tool +# 2) The IL splits +# Also return the differences between both +# TODO: Support fractional scores properly here (probably need to modify nclone) +def send_splits(event) + # Parse message parameters + msg = parse_message(event) + ep = parse_highscoreable(event, mappack: true) + perror("Splits can only be computed for episodes.") if !ep.is_episode? + mappack = ep.is_mappack? + board = parse_board(msg, 'hs') + perror("Speedrun mode isn't available for Metanet levels yet.") if !mappack && board == 'sr' + perror("Splits are only available for either highscore or speedrun mode.") if !['hs', 'sr'].include?(board) + frac = false # parse_frac(msg, mappack, board) + scores = ep.leaderboard(board, pluck: false, frac: frac) + rank = parse_range(msg)[0] + ntrace = board == 'hs' # Requires ntrace + score = scores[rank] + perror("No score in #{ep.name} with rank #{rank.ordinalize}") if !score + warnings = [] + + # Episode splits and scores + res = score.splits(board, event: event, frac: frac) + success = !!res + if success + valid, ep_splits, ep_scores = res[:valid], res[:splits], res[:scores] + else + valid, ep_splits, ep_scores = [false] * 5, [0] * 5, [0] * 5 + end + + # Level splits and scores + lvl_splits = ep.splits(rank, board: board, frac: frac) + if lvl_splits.nil? + event << "Sorry, that rank doesn't seem to exist for at least some of the levels." + return + end + scoref = !mappack ? 'score' : "score_#{board}" + factor = mappack && board == 'hs' ? 60.0 : 1 + lvl_scores = ep.levels.map{ |l| l.leaderboard(board, frac: frac)[rank][scoref] / factor } + + # Calculate differences + full = !ntrace || success + if full + errors = valid.count(false) + if errors > 0 + wrong = valid.each_with_index.map{ |v, i| !v ? i.to_s : nil }.compact.to_sentence + warnings << "-# **Warning**: Couldn't calculate episode splits (error in #{'level'.pluralize(errors)} #{wrong})." + full = false + else + diff_splits = lvl_splits.each_with_index.map{ |ls, i| + mappack && board == 'sr' ? ep_splits[i] - ls : ls - ep_splits[i] + } + diff_scores = diff_splits.each_with_index.map{ |d, i| + round_score(i == 0 ? d : d - diff_splits[i - 1]) + } + end + else + reason = !FEATURE_NTRACE ? 'NSim is disabled' : 'NSim failed.' + warnings << "-# **Warning**: Episode splits and diffs aren't available. Reason: #{reason}." + end + + # Format response + rows = [] + rows << ['', '00', '01', '02', '03', '04'] + rows << :sep + rows << ['Ep splits', *ep_splits] if full + rows << ['Lvl splits', *lvl_splits] + rows << ['Total diff', *diff_splits] if full + rows << :sep if full + rows << ['Ep scores', *ep_scores] if full + rows << ['Lvl scores', *lvl_scores] + rows << ['Ind diffs', *diff_scores] if full + + event << "#{rank.ordinalize} #{format_board(board)} splits for episode #{ep.name}:" + warnings.each{ |w| event << w } + event << format_block(make_table(rows)) +rescue => e + lex(e, "Error calculating splits.", event: event) +end + +# Look for unsubmitted level scores in episode runs and resubmit them +# TODO: What happens if we don't know the users Steam ID? +def fix_episode(event) + msg = parse_message(event) + ep = parse_highscoreable(event, mappack: true) + perror("You have to specify an episode.") if !ep.is_episode? + perror("The episode must be a vanilla Metanet episode.") if ep.is_mappack? + player = parse_player(event, false, false, true) + perror("I don't know #{player.name}'s Steam ID, ask the botmaster to add it.") if !player.steam_id + debug = !!msg[/\bdebug\b/i] && check_permission(event, 'ntracer') + + # Get player's episode and level scores from the server + TmpMsg.update(event, '-# Fetching scores from the server...') + ep_score = player.get_score(ep, discord: true) + return if !ep_score + ep_replay = player.get_replay(ep, ep_score[:replay_id], discord: true) + return if !ep_replay + lvl_scores = ep.levels.map{ |lvl| _thread{ player.get_score(lvl) } }.map(&:value) + + # Compute episode level scores using NSim + TmpMsg.update(event, '-# Running simulation...') + nsim = NSim.new(ep.levels.map{ |l| l.map.dump_level }, Demo.encode(ep_replay)) + nsim.run + nsim.check(event, debug: debug) + ep_scores = { valid: nsim.valid_flags, splits: nsim.splits, scores: nsim.scores } + nsim.destroy + + # Print results + output = "" + rows = [] + rows << ["", "00", "01", "02", "03", "04"] + rows << :sep + rows << ["Episode score", *ep_scores[:scores]] + row = ["Level score"] + lvl_scores.each_with_index{ |s, i| + next row << ANSI.bad + 'None' + ANSI.clear if !s + diff = s[:score] - ep_scores[:scores][i] + color_start = diff > 0.001 ? ANSI.good : diff < -0.001 ? ANSI.bad : '' + color_end = diff.abs > 0.001 ? ANSI.clear : '' + row << color_start + "%.3f" % [round_score(s[:score])] + color_end + } + rows << row + output << mdhdr2("⚙ __Episode score patcher__\n") + output << "Episode #{verbatim(ep.name)} scores vs level scores for #{verbatim(player.print_name)}:\n" + output << format_block(make_table(rows)) + + # Resubmit improved ones + to_submit = 5.times.select{ |i| + !lvl_scores[i] || ep_scores[:scores][i] > lvl_scores[i][:score] + 0.001 + } + if to_submit.empty? + output << "No level scores need resubmission ✅\n" + event << output + return + end + output << "**#{to_submit.size}** level #{'score'.pluralize(to_submit.size)} need resubmission.\n" + output << mdhdr3("═══ § Score submission\n") + TmpMsg.update(event, '-# Submitting level scores...') + ep.levels.each_with_index{ |lvl, i| + next unless to_submit.include?(i) + res = lvl.submit_score(ep_scores[:scores][i], [ep_replay[i].bytes], player, log: true) + if !res + output << "❌ Submission to level #{verbatim(lvl.name)} failed.\n" + else + output << "✅ Submitted new score to level #{verbatim(lvl.name)} ➡️ " + output << "Old rank: **#{lvl_scores[i][:rank] rescue 'None'}**. New rank: **#{res['rank']}**.\n" + end + } + event << output +rescue => e + lex(e, "Error fixing episode scores.", event: event) +end + +# Command to allow SimVYo to dynamically update his ntrace tool by sending the +# files via Discord +def update_ntrace(event) + # Ensure only those allowed can do this + assert_permissions(event, ['ntracer']) + msg = "" + yes = [] + no = [] + + ['ntrace', 'nsim', 'nplay'].each{ |filename| + # Fetch attached file and perform integrity checks + files = event.message.attachments.select{ |a| a.filename == "#{filename}.py" } + if files.size == 0 + no << filename + next + end + if files.size > 1 + msg << "Didn't update #{verbatim("#{filename}.py")}: Too many #{filename}.py files found.\n" + no << filename + next + end + file = files.first + if file.size > 1024 ** 2 + msg << "Didn't update #{verbatim("#{filename}.py")}: File is too large.\n" + no << filename + next + end + res = Net::HTTP.get(URI(file.url)) + if res.size != file.size + msg << "Didn't update #{verbatim("#{filename}.py")}: File is corrupt.\n" + no << filename + next + end + + # Update file + path = File.join(File.dirname(PATH_NTRACE), "#{filename}.py") + old_date = File.mtime(path) rescue nil + old_size = File.size(path) rescue nil + File.binwrite(path, res) + new_date = File.mtime(path) rescue nil + new_size = File.size(path) rescue nil + msg << (new_date.nil? ? "Failed to update #{verbatim("#{filename}.py")}." : "Updated #{verbatim("#{filename}.py")} successfully.") + versions = '' + versions << "Old version: #{old_date.strftime('%Y/%m/%d %H:%M:%S')} (#{old_size} bytes)\n" if !old_date.nil? + versions << "New version: #{new_date.strftime('%Y/%m/%d %H:%M:%S')} (#{new_size} bytes)\n" if !new_date.nil? + msg << format_block(versions) + yes << filename + } + msg << "Updated files: #{yes.map{ |fn| verbatim(fn) }.join(', ')}.\n" unless yes.empty? + msg << "Not updated files: #{no.map{ |fn| verbatim(fn) }.join(', ')}." unless no.empty? + + event << "**ntrace update**:" + event << msg + Thread.new { ld("#{event.user.name} updated ntrace:\n#{msg}") } +rescue => e + lex(e, "Error updating ntrace.", event: event) +end + +# Sends a PNG graph plotting the evolution of player's scores (e.g. top20 count, +# 0th count, points...) over time. +# Currently unavailable because the db structure changed between CCS and Eddy +def send_history(event) + event << "Function not available yet, restructuring being done (since 2020 :joy:)." +end + +def identify(event) + msg = parse_message(event) + user = event.user.name + nick = msg[/my name is (.*)[\.]?$/i, 1] + perror("You have to send a message in the form #{verbatim('my name is ')}.") if nick.nil? + + player = parse_player(event, false, true, true, false, true) + user = parse_user(event.user) + user.player = player + + event << "Awesome! From now on you can omit your username and I'll look up scores for #{player.name}." +rescue => e + lex(e, "Error identifying.", event: event) +end + +def add_display_name(event) + msg = parse_message(event) + name = msg[/my display name is (.*)[\.]?$/i, 1] + perror("You need to specify some display name.") if name.nil? + user = parse_user(event.user) + player = user.player + perror("I don't know what player you are yet, specify it first using #{verbatim('my name is ')}.") if !player + player.update(display_name: name) + event << "Great, from now on #{player.name} will show up as #{name}." +rescue => e + lex(e, "Error changing display name.", event: event) +end + +def set_default_palette(event) + msg = parse_message(event) + palette = msg[/my palette is (.*)[\.\s]*$/i, 1] + perror("You need to specify a palette name.") if palette.nil? + palette = parse_palette(event, pal: palette, fallback: false)[:palette] + user = parse_user(event.user) + user.update(palette: palette) + event << "Great, from now on your default screenshot palette will be #{verbatim(palette)}." +rescue => e + lex(e, "Error setting default palette.", event: event) +end + +def set_default_mappack(event) + msg = parse_message(event) + pack = msg[/my (?:.*?)(?:map\s*)?pack (?:.*?)is (.*)[\.\s]*$/i, 1] + always = !!msg[/always/i] + perror("You need to specify a mappack.") if pack.nil? + mappack = parse_mappack(pack, explicit: true, vanilla: false) + perror("Mappack not recognized.") if mappack.nil? + parse_user(event.user).update( + mappack_id: mappack.id, + mappack_default_always: always, + mappack_default_dms: true + ) + places = always ? 'Every channel' : "DMs" + event << "Great, from now on your default mappack will be #{verbatim(mappack.name)}. It will be used in: #{places}." +rescue => e + lex(e, 'Error setting default mappack.') +end + +def set_default_mappacks(event) + user = parse_user(event.user) + val = user.mappack_defaults + user.update(mappack_defaults: !val) + event << "From now on, mappacks #{val ? "won't" : 'will'} be used by default in their respective channels (for you)." +end + +def hello(event) + update_bot_status + event << "Hi!" + set_channels(event) if $channel.nil? +rescue => e + lex(e, "Error during hello sequence.") +end + +def thanks(event) + event << "You're welcome!" +end + +def faceswap(event) + old_avatar = GlobalProperty.get_avatar + avatars = Dir.entries(PATH_AVATARS) + .select{ |f| File.file?(File.join(PATH_AVATARS, f)) } + .reject{ |f| f == old_avatar} + perror("No new avatars available!") if avatars.empty? + new_avatar = avatars.sample + change_avatar(new_avatar) +rescue + perror("Failed to change avatar.") +else + GlobalProperty.set_avatar(new_avatar) +end + +def send_help(event, section: nil, subsection: nil, page: nil) + # Main sections of the help + root = Doc.new('Help') + sect_names = [ + 'Introduction', 'Commands', 'Userlevels', 'Mappacks', 'Events & Tasks', + 'History', 'For Developers', 'Credits' + ] + sects = sect_names.map{ |name| [name, Doc.new(name)] }.to_h + sects.each{ |_, sect| root << sect } + + # Sec. 1 - Introduction + emoji = (find_emoji('moleHi') || '👋').to_s + str = "Hi #{emoji} I'm **outte++**, the N++ Discord chatbot and inne++'s evil cousin. " + str << "I provide functionality related to many aspects of the game, such as *highscoring*, *userlevels*, custom *mappacks*, and more. " + str << "The help is broken down into sections (and possibly subsections) and pages. " + str << root.toc + str << "Use the menus below to navigate the sections, and the buttons to navigate the pages of each section, if any. " + str << "Finally, if in doubt, don't hesitate to ask my good friend *Eddy* for help!\n" + sects['Introduction'].write(str) + + # Sec. 8 - Credits + str = <<~STR + This bot was originally developed and maintained by **CCS**, check out the + #{mdurl('original repo', '')}. It's currently maintained + by **Eddy**, check out the #{mdurl('current repo', '')}. + This bot built on ideas developed for the previous iteration of the game, + #{mdurl('N','')}, notably + #{mdurl('NHigh', '')} by **jg9000**, + which was focused solely around highscoring. To learn more details about the history, + check out the _History_ section below. + STR + sects['Credits'].write(str.tr("\n", '')) + + # Parse section and page numbers + reset_page = !!section || !!subsection + msg = parse_message(event) + pages = 1 + page = parse_page(msg, page, reset_page, event.message.components) % pages + 1 + indices = msg[/\[(.+)\]/, 1] + indices = indices ? indices.split('.').map{ |i| i.to_i - 1 } : [0] + indices[0] = section.to_i if section + indices[1] = subsection.to_i if subsection + cur_name = sect_names[indices.first] + msg = root.render(*indices) + + # Format message + view = Discordrb::Webhooks::View.new + interaction_add_button_navigation_short(view, page, pages, 'send_help') unless pages == 1 + interaction_add_select_menu(view, 'help:sect', sect_names, cur_name) + send_message(event, content: msg, components: view) + + # TODO: + # - Add pages to Doc class +rescue => e + lex(e, "Error sending help.", event: event) +end + +# Send info about current and next lotd/eotw/cotm +def send_lotd(event, type = Level) + msg = parse_message(event) + ctp = !!msg[/\bctp\b/i] + h = GlobalProperty.get_current(type, ctp) + event << lotd_reminder(type, ctp: ctp) + event.attach_file(send_screenshot(event, h, ret: true)[0]) if h +rescue => e + lex(e, "Error sending lotd/eotw/cotm info.", event: event) +end + +def send_unique_holders(event) + ranks = Score.holders + ranks = ranks.map{ |r, c| "#{"%02d" % r} - #{"%3d" % c}" }.join("\n") + event << "Number of unique highscore holders by rank at #{Time.now.to_s}\n#{format_block(ranks)}" +rescue => e + lex(e, "Error computing unique holders.", event: event) +end + +# TODO: Implement a way to query next pages if there are more than 20 streams. +# ... who are we kidding we'll never need this bahahahah. +def send_twitch(event) + Twitch::update_twitch_streams + streams = Twitch::active_streams + + event << "Currently active N related Twitch streams #{format_time}:" + if streams.map{ |k, v| v.size }.sum == 0 + event << "None :shrug:" + else + str = "" + streams.each{ |game, list| + if list.size > 0 + str += "**#{game}**: #{list.size}\n" + ss = list.take(20).map{ |stream| Twitch::format_stream(stream) }.join("\n") + str += format_block(Twitch::table_header + "\n" + ss) + end + } + event << str if !str.empty? + end +rescue => e + lex(e, "Error getting current Twitch N++ streams.", event: event) +end + +# Add role to player (internal, for permission system, not related to Discord roles) +# Example: Add role "dmmc" for Donfuy +def add_role(event) + assert_permissions(event) + + msg = parse_message(event) + user = parse_discord_user(msg) + + role = parse_term(msg) + perror("You need to provide a role in quotes.") if role.nil? + + Role.add(user, role) + event << "Added role \"#{role}\" to #{user.name}." +rescue => e + lex(e, "Error adding role.", event: event) +end + +# Add custom player / level alias. +# Example: Add level alias "sss" for sigma structure symphony +def add_alias(event) + assert_permissions(event) # Only the botmaster can execute this + + msg = parse_message(event) + aka = parse_term(msg) + perror("You need to provide an alias in quotes.") if aka.nil? + + msg.remove!(aka) + type = !!msg[/\blevel\b/i] ? 'level' : (!!msg[/\bplayer\b/i] ? 'player' : nil) + perror("You need to provide an alias type: level, player.") if type.nil? + + entry = type == 'level' ? parse_highscoreable(event) : parse_player(event) + entry.add_alias(aka) + event << "Added alias \"#{aka}\" to #{type} #{entry.name}." +rescue => e + lex(e, "Error adding alias.", event: event) +end + +# Send custom player / level aliases. +# ("type" has to be either 'level' or 'player' for now) +def send_aliases(event, page: nil, type: nil) + # PARSE + reset_page = !type.nil? + msg = parse_message(event) + type = parse_alias_type(msg, type) + page = parse_page(msg, page, reset_page, event.message.components) + case type + when 'level' + klass = LevelAlias + klass2 = :level + name = "`#{klass2.to_s.pluralize}`.`longname`" + when 'player' + klass = PlayerAlias + klass2 = :player + name = "`#{klass2.to_s.pluralize}`.`name`" + else + perror("Incorrect alias type (should be #{verbatim('player')} or #{verbatim('level')})") + end + + # COMPUTE + count = klass.count.to_i + pag = compute_pages(count, page) + aliases = klass.joins(klass2).order(:alias).offset(pag[:offset]).limit(PAGE_SIZE).pluck(:alias, name) + + # FORMAT + pad = aliases.map(&:first).map(&:length).max + block = aliases.map{ |a| + name1 = pad_truncate_ellipsis(a[0], pad, 15) + name2 = truncate_ellipsis(a[1], 35) + "#{name1} #{name2}" + }.join("\n") + output = "Aliases for #{type} names (total #{count}):\n#{format_block(block)}" + + # SEND + view = Discordrb::Webhooks::View.new + interaction_add_button_navigation(view, pag[:page], pag[:pages]) + interaction_add_select_menu_alias_type(view, type) + send_message(event, content: output, components: view) +rescue => e + lex(e, "Error fetching aliases.", event: event) +end + +# Convert N v1.4 maps to N++ maps. Maps can be supplied by either: +# - Attaching one or more userlevel files, format in the standard way. +# - Pasting valid map data directly within the command in Discord. +def send_convert(event) + # Parse message + msg = parse_message(event) + nv14 = !!msg[/n?v1\.?4/i] # Provided maps are from N v1.4 + nv2 = !!msg[/n?v2/i] # Provided maps are from N v2 + prefix = !!msg[/\bprefix\b/i] # Numeric prefix should be prepended to map titles + author = !!msg[/\bauthors?\b/i] # Author name should be appended to level titles + comments = !!msg[/\bcomments?\b/i] # Comments / level type should be apprended to level titles + perror("nv2 map conversion is not supported yet") if nv2 + title = "⚙ __Converter: N v1.4 ➤ N++__" + + # Map data format string + col1 = ANSI.yellow + col2 = ANSI.cyan + col3 = ANSI.magenta + header = "#{col1}title#{col3}##{col1}author#{col3}##{col1}comments#{col3}" + map_data = "#{col2}tiles#{col3}|#{col2}objects#{col3}" + map_fmt = format_block("#{col3}$#{header}##{map_data}#") + + # Fetch map data + map_data = msg[NV14_USERLEVEL_PATTERN] || msg[NV14_MAP_PATTERN] + attachments = fetch_attachments(event) + attachments['msg_map_data'] = map_data if map_data + if attachments.empty? + err_msg = <<~ERR.gsub(/CONT\n/, ' ') + # #{title} + ### ═══ 📜 Instructions + You need to provide valid N v1.4 map data together with the convert command, by either: + * Attaching one or more userlevel text files. + * Pasting the data for _one_ level in the message directly, if it fits. + You can even combine both methods. + ### ═══ 💾 Map data format + For maximum safety, map data should follow the usual v1.4 format: + #{map_fmt} With **one** map per line (metadata fields can be empty). + -# (The scanner is actually way more flexible, and normally you can get away with justCONT + passing the raw map data as returned by Ned, with multiple maps per line, etc.CONT + But the more you depart from these guidelines, the more likely it is thatCONT + scanning could fail or be incorrect.) + ### ═══ 🔧 Options + The following settings can also be optionally specified: + * **prefix** - Add numerical prefix before titles, to keep the original map order.CONT + Ideal for later tab building. + * **author** - Append author name to map titles. + * **comments** - Append comments to map titles. + ERR + perror(err_msg) + end + count = attachments.size + resp = "" + files = [] + + # Parse files + resp << mdhdr1(title) << "\n" + resp << "Found **#{count < 10 ? EMOJI_NUMBERS[count] : count}** #{'source'.pluralize(count)}.\n" + attachments.each{ |name, body| + file = name != 'msg_map_data' + name = 'conversion' if !file + header = file ? "file: #{verbatim(name)}" : 'supplied map data:' + resp << mdhdr3("═══ § Converting #{header}\n") + warnings = {} + res = Map.convert_nv14_file(filename: name, content: body, warnings: warnings, prefix: prefix, burn_author: author, burn_comments: comments) + + # Error parsing file + if !res + resp << mdsub("📊 Status: Error ❌\n") + resp << mdsub("Internal error converting the #{file ? 'file' : 'map'}. Please report to Eddy.\n") + next + end + + # No maps found in file + if res[:count] == 0 + resp << mdsub("📊 Status: Error ❌\n") + resp << mdsub("No valid map data found. Ensure it follows this format, one map per line:\n") + resp << map_fmt + "\n" + resp << mdsub("You _might_ be able to get away by just passing the raw map data, but this is safer.\n") + next + end + + # Maps found and converted + checks = warnings.inject(0){ |sum, pair| + sum + pair[1].inject(0){ |sz, p| sz + [p[1].size, 1].max } + } + resp << mdsub("📊 Status: Success ✅\n") + resp << mdsub("🗺 Maps: #{res[:count]}\n") + resp << mdsub("⚠️ Maps w/ warnings: #{warnings.size}\n") + resp << mdsub("🔎 Checks needed: #{checks}\n") + + # Attach map file (ZIP if multiple) and warnings if any + files << tmp_file(res[:file], res[:name], binary: true) + if !warnings.empty? + warnings = warnings.map{ |level, types| + level + types.map{ |type, list| + "\n " + type + list.map{ |w| + "\n - " + w + }.join + }.join + }.join("\n\n") + filename = res[:name].sub(/\.zip$/, '') + '_warnings.txt' + if count == 1 && checks <= 20 && (resp + warnings).length <= DISCORD_CHAR_LIMIT - 50 + resp << format_block(warnings) + else + files << tmp_file(warnings, filename, binary: false) + end + end + } + + send_message(event, content: resp, files: files) +rescue => e + lex(e, "Error converting maps.", event: event) +end + +# Function to autogenerate screenshots of the userlevels for the dMMc contest +# in random palettes, zip them, and upload them. +def send_dmmc(event) + assert_permissions(event, ['dmmc']) + edition = parse_message(event)[/\d+/].to_i + levels = Userlevel.where_like('title', "MM#{edition}").order(:id).limit(100) + count = levels.count + palettes = Palette::NAMES.dup + str = '' + zip_buffer = Zip::OutputStream.write_buffer{ |zip| + levels.each_with_index{ |u, i| + TmpMsg.update(event, "-# Generating screenshot #{i + 1} of #{count}...") if i % 5 == 4 + palette = palettes.sample + zip.put_next_entry(sanitize_filename(u.author.name) + ' - ' + sanitize_filename(u.title) + '.png') + zip.write(u.screenshot(palette)) + palettes.delete(palette) + str << verbatim(u.title) << ' by ' << u.author.name << "\n" + } + } + zip = zip_buffer.string + event << "MM#{edition} has #{count} maps:\n#{str}" + send_file(event, zip, 'dmmc.zip', true) +rescue => e + lex(e, "Error fetching dMMc maps.", event: event) +end + +# Send information about the latest submitted speedruns or fetch some leaderboard +def send_speedruns(event, page: nil, game: nil, category: nil, variable: nil, value: nil) + # Ensure we have access to at least the basic information from the API + perror("Failed to fetch info from Speedrun.com") if !Speedrun::ensure_basic_info + + # Parse the message + msg = parse_message(event) + newest = !!msg[/\bnewest\b/] || !!msg[/\blatest\b/] + view = Discordrb::Webhooks::View.new + + # Return the latest submitted speedruns formatted in a table + if newest + runs = Speedrun::GAMES.map{ |id, name| + Speedrun.fetch_runs(id) + }.flatten.sort_by{ |run| run[:date_submitted] }.reverse.take(SPEEDRUN_NEW_COUNT) + runs = Speedrun.format_table(runs) + output = "Latest submitted speedruns:\n" + format_block(runs) + return send_message(event, content: output, components: view) + end + + # Validate input, use default values if invalid + game = Speedrun.sanitize_game(game || get_menu_value(event, 'speedrun:game')) + category = Speedrun.sanitize_category(game, category || get_menu_value(event, 'speedrun:category')) + categories = Speedrun.get_game(game)[:categories] + variables = Speedrun.get_category(game, category)[:variables].select{ |id, var| var[:subcategory] } + values = variables.map{ |id, var| + val = variable == id ? value : get_menu_value(event, "speedrun:var-#{id}") + val = Speedrun.sanitize_value(game, category, id, val) + [id, val] + }.to_h + + # Fetch leaderboards and format them + boards = Speedrun.fetch_boards(game, category, variables: values) + reset_page = !parse_initial(event) && !page + page = parse_page(msg, page, reset_page, event.message.components) + pag = compute_pages(boards[:count], page, SPEEDRUN_BOARD_COUNT) + offset = SPEEDRUN_BOARD_COUNT * (pag[:page] - 1) + 1 + range = (offset...offset + SPEEDRUN_BOARD_COUNT) + boards[:runs].select!{ |run| range.cover?(run[:place]) } + embed = Speedrun.format_boards(boards) + + # Components + interaction_add_button_navigation(view, pag[:page], pag[:pages], func: 'send_speedruns', source: 'speedrun') + view.row{ |r| + r.select_menu(custom_id: "speedrun:game:#{game}", placeholder: 'Game', max_values: 1){ |m| + Speedrun::GAMES.each{ |id, name| + m.option(label: 'Game: ' + name, value: id, default: id == game) + } + } + } + view.row{ |r| + r.select_menu(custom_id: "speedrun:category:#{category}", placeholder: 'Category', max_values: 1){ |m| + categories.each{ |id, cat| + m.option(label: 'Category: ' + cat[:name], value: id, default: id == category) + } + } + } + variables.each{ |var_id, var| + view.row{ |r| + r.select_menu(custom_id: "speedrun:var-#{var_id}:#{values[var_id]}", placeholder: var[:name], max_values: 1){ |m| + var[:values].each{ |val_id, val| + m.option(label: "%s: %s" % [var[:name], val[:name]], value: val_id, default: val_id == values[var_id]) + } + } + } + } + + send_message(event, embeds: [embed], components: view) +end + +def mishnub(event) + youmean = ["More like ", "You mean ", "Mish... oh, ", "Better known as ", "A.K.A. ", "Also known as "] + mishu = ["MishNUB,", "MishWho?,"] + amirite = [" amirite", " isn't that right", " huh", " am I right or what", " amirite or amirite"] + fellas = [" fellas", " boys", " guys", " lads", " fellow ninjas", " friends", " ninjafarians"] + laugh = [" :joy:", " lmao", " hahah", " lul", " rofl", " <:moleSmirk:336271943546306561>", " <:Kappa:237591190357278721>", " :laughing:", " rolfmao"] + if rand < 0.05 && (event.channel.type == 1 || $last_mishu.nil? || !$last_mishu.nil? && Time.now.to_i - $last_mishu >= MISHU_COOLDOWN) + send_message(event, db: false, content: youmean.sample + mishu.sample + amirite.sample + fellas.sample + laugh.sample) + $last_mishu = Time.now.to_i unless event.channel.type == 1 + end +end + +def robot(event) + start = ["No! ", "Not at all. ", "Negative. ", "By no means. ", "Most certainly not. ", "Not true. ", "Nuh uh. "] + middle = ["I can assure you he's not", "Eddy is not a robot", "Master is very much human", "Senpai is a ningen", "Mr. E is definitely human", "Owner is definitely a hooman", "Eddy is a living human being", "Eduardo es una persona"] + ending = [".", "!", " >:(", " (ಠ益ಠ)", " (╯°□°)╯︵ ┻━┻"] + send_message(event, db: false, content: start.sample + middle.sample + ending.sample) +end + +def send_denubbed(event, page: nil) + msg = parse_message(event) + mishu_id = 115572 + + outer_binding = binding + join = -> (name, id = true) { + temp_table = outer_binding.local_variable_get(name.to_sym).to_sql + str = "INNER JOIN (#{temp_table}) AS `#{name}` ON " + str << "`archives`.`highscoreable_type` = `#{name}`.`highscoreable_type`" + str << " AND `archives`.`highscoreable_id` = `#{name}`.`highscoreable_id`" if id + str + } + + # Get 0th scores by maximizing score + zero_scores = Archive.group(:highscoreable_type, :highscoreable_id) + .select(:highscoreable_type, :highscoreable_id, 'MAX(`score`) AS `score`') + + # Get actual 0ths by minimizing replay ID + zeroes = Archive.joins(join.('zero_scores')) + .where('`archives`.`score` = `zero_scores`.`score`') + .group(:highscoreable_type, :highscoreable_id) + .select(:highscoreable_type, :highscoreable_id, 'MAX(`archives`.`score`) AS `score`', 'MIN(`archives`.`replay_id`) AS `replay_id`') + + # Get differences between 0th and Mishu + denubbings = Archive.joins(join.('zeroes')) + .where(metanet_id: mishu_id) + .select(:highscoreable_type, :replay_id, '`zeroes`.`replay_id` AS `zero_replay_id`', '`zeroes`.`score` - `archives`.`score` AS `diff`') + .having('`diff` > 0 OR `diff` = 0 AND `archives`.`replay_id` > `zeroes`.`replay_id`') + .order(diff: :desc, highscoreable_id: :asc) + + # Get fields + final = Archive.joins(join.('denubbings', false) + ' AND `archives`.`replay_id` = `denubbings`.`zero_replay_id`') + .joins("INNER JOIN `players` ON `players`.`id` = `archives`.`player_id`") + .joins("INNER JOIN `levels` ON `levels`.`id` = `archives`.`highscoreable_id`") + .where(highscoreable_type: 'Level') + .order(diff: :desc) + .pluck('`levels`.`name`', '`denubbings`.`diff`', '`players`.`name`') + + # Format list + list = final.map{ |level, diff, player| + "%-10s %2df - %s" % [level, diff, player] + } + pager(event, page, header: 'Denubbed boards', list: list, func: 'send_denubbed') +rescue => e + lex(e, "Error fetching denubbed scores.", event: event) +end + +def send_tip(event, page: nil) + tips = [ + "If you identify, you will be able to omit your player name in many highscoring commands. "\ + "You can do this with the command `my name is` followed by your N++ (Steam) username. "\ + "Now you can run e.g. `how many top20` and it will respond with your top20 count.", + + "You can choose a default palette with the command `my palette is`, followed by the palette "\ + "name (e.g. `my palette is retro`). This palette will be used by default when generating "\ + "screenshots or animations. All 123 official palettes are supported, see the full list "\ + "#{mdurl('here', '')}.", + + "You can refer to levels in up to 3 different ways: the _ID_ (e.g. `S-C-19-04`), the _name_ "\ + "(e.g. `nonplusplussed`) and the _alias_ (e.g. `-++`). Not all levels have aliases, they are "\ + "added manually by the botmaster. Players can also have aliases. You can use the `aliases` "\ + "command to see the full list.", + + "You can shorten level IDs by omitting the dashes and extra zeroes if there's no ambiguity. "\ + "For instance, level `S-B-01-03` can be referred to as simply `sb13`. In case of ambiguity, "\ + "levels take precedence, so episode `S-B-13` cannot be shortened to `sb13`, because it would "\ + "be confused with the previous level. In this case, the dashes are needed.", + + "Whenever you need to specify a name (player name, level name, palette name, etc) you can "\ + "always put it at the end using the prefix **for** (e.g. `scores for the basics`), but you "\ + "can also use quotes, which will specially help when there are multiple such terms. For "\ + "instance, `browse userlevels for \"house\" by \"yefffef\"` will search for all userlevels "\ + "by the author `yefffef` having the word `house` in the title.", + + "There is fuzzy searching for level names and palette names, which means that you can enter "\ + "incomplete or approximate names and the closest matches will the found. If there's a single "\ + "one, it will be used. If there are multiple good matches, the list will be printed.", + + "You can choose a default mappack with the command `my mappack is` followed by the mappack's "\ + "name or 3 letter code. That mappack will be used by default from then on in all DM commands. "\ + "Use the `mappacks` command to find out the list of all supported mappacks.", + + "You can use outte++ as a 3rd party userlevel **search engine** for N++ with many more "\ + "features than the vanilla game (search by author, custom sorting, etc). To do this, "\ + "simply perform any userlevel search in outte "\ + "and click the green button that says \"Play\". Then go to the game and search `outte`, and "\ + "those levels will appear instead! For this to work, you need to patch your game, see "\ + "#{mdurl('here', 'https://discord.com/channels/197765375503368192/221721273405800458/1185641339254222991')}"\ + " for instructions.", + + "outte++ is open source and you can find its code in #{mdurl('Github', '')}. "\ + "You are welcome to peruse it and, if you develop enough understanding of it, contribute to it as well." + ] + msg = parse_message(event) + page = parse_page(msg, page, false, event.message.components, default: rand(tips.size) + 1) + pag = compute_pages(tips.size, page, 1) + str = mdtext("🔎 __Did you know...__\n", header: 1) + tips[pag[:page] - 1] + components = interaction_add_button_navigation_short(nil, pag[:page], pag[:pages], 'send_tip', wrap: true) + send_message(event, content: str, components: components) +end + +# Configure the settings for a user +def send_config(event, **options) + user = parse_user(event.user) + msgs = [] + + # Parse player name + if player_name = options[:player] + player = parse_player_explicit(player_name, rais: false) + if !player + msgs << [:bad, "Player by the name #{verbatim(player_name)} not found"] + else + user.update(player: player) + msgs << [:good, "You are now identified as #{verbatim(player.name)}"] + end + end + + # Parse display name + if display_name = options[:nickname] + if !user.player + msgs << [:bad, "I don't know who you are, you need to specify the `player` as well"] + else + user.player.update(display_name: display_name) + msgs << [:good, "Your name will now show as #{verbatim(display_name)}"] + end + end + + # Parse default palette + if palette = options[:palette] + begin + palette = parse_palette(event, pal: palette, fallback: false)[:palette] + rescue OutteError => e + msgs << [:bad, e.message.strip] + else + user.update(palette: palette) + msgs << [:good, "Your default palette is now #{verbatim(palette)}"] + end + end + + # Parse default mappack + if pack = options[:mappack] + mappack = parse_mappack(pack, explicit: true, vanilla: false) + if mappack.nil? + msgs << [:bad, "Mappack #{verbatim(pack)} not recognized"] + else + met = mappack.id == 0 + user.update( + mappack_id: mappack.id, + mappack_defaults: !met, + mappack_default_always: !met, + mappack_default_dms: !met + ) + msgs << [:good, "Your default mappack is now #{verbatim(mappack.name)}"] + end + end + + errors = msgs.count{ |status, msg| status == :bad } + str = "🔧 Changed your configuration" + (errors > 0 ? " with #{errors} errors" : '!') + msgs.each{ |status, msg| + str << "\n-# " << (status == :good ? '✅' : '❌') << ' ' << msg + } + send_message(event, content: str) +rescue => e + lex(e, 'Failed to change configuration', event: event) +end + +# Handle responses to new reactions +def respond_reaction(event) + # Only have tasks for reactions to outte messages + msg = event.message + return if msg.user.id != $config['discord_client'] + emoji = event.emoji.to_s + + # Public reaction commands + return report_message(event) if EMOJIS_TO_DELETE.include?(emoji) + + # Botmaster exclusive reaction commands + return if !is_botmaster?(event) + return Twitch::ban_stream(event) if emoji == EMOJI_TO_BAN +end + +# Main function that coordinates responses to commands issued in Discord +# via DMs or pings +def respond(event) + msg = parse_message(event) + hm = !msg[/\bhow many\b/i] + + # Divert flow to userlevel specific functions + return respond_userlevels(event) if !!msg[/userlevel/i] + + action_inc('commands') + + # Exclusively global methods + if !msg[NAME_PATTERN, 2] + return send_rankings(event) if msg =~ /rank/i && msg !~ /history/i && msg !~ /table/i + return send_history(event) if msg =~ /history/i + return send_community(event) if msg =~ /community/i + return send_cleanliness(event) if msg =~ /cleanest/i || msg =~ /dirtiest/i + return send_ownages(event) if msg =~ /ownage/i + return send_random(event) if msg =~ /random/i + return send_help(event) if msg =~ /\bhelp\b/i || msg =~ /\bcommands\b/i + return send_help2(event) if msg =~ /help2/i + end + + # A single message could trigger multiple commands. To prevent this, we return + # when the first command is triggered. Therefore, the ordering of these matters, + # so we sort them according to certain priorities. + # For example, we put the ones that take level names first, since those may + # contain many other words that could accidentally trigger commands. + return send_query(event) if msg =~ /\bsearch\b/i || msg =~ /\bbrowse\b/i + return send_screenshot(event) if msg =~ /screenshot/i + return send_scores(event) if msg =~ /scores/i + return send_analysis(event) if msg =~ /analysis/i + return send_level_name(event) if msg =~ /\blevel name\b/i + return send_level_id(event) if msg =~ /\blevel id\b/i + return send_challenges(event) if msg =~ /\bchallenges\b/i + return send_videos(event) if msg =~ /\bvideos\b/i + return add_alias(event) if msg =~ /\badd\s*(level|player)?\s*alias\b/i + return send_demo_download(event) if (msg =~ /\breplay\b/i || msg =~ /\bdemo\b/i) && msg =~ /\bdownload\b/i + return send_download(event) if msg =~ /\bdownload\b/i + return send_trace(event) if msg =~ /\btrace\b/i || msg =~ /\banim/i + return send_diff(event) if msg =~ /\bdiff(ences?)?\b/i + return send_speedruns(event) if msg =~ /\bspeed\s*runs?\b/ + return send_lotd(event, Level) if msg =~ /lotd/i + return send_lotd(event, Episode) if msg =~ /eotw/i + return send_lotd(event, Story) if msg =~ /cotm/i + return send_table(event) if msg =~ /\btable\b/i + return send_average_points(event) if msg =~ /\bpoints/i && msg =~ /average/i + return send_points(event) if msg =~ /\bpoints/i + return send_spreads(event) if msg =~ /spread/i + return send_average_rank(event) if msg =~ /average/i && msg =~ /rank/i && msg !~ /history/i && !!msg[NAME_PATTERN, 2] + return send_average_lead(event) if msg =~ /average/i && msg =~ /lead/i && msg !~ /rank/i + return send_total_score(event) if msg =~ /total\b/i && msg !~ /history/i && msg !~ /rank/i + return send_maxable(event) if msg =~ /maxable/i + return send_maxed(event) if msg =~ /maxed/i + return send_tally(event) if msg =~ /\btally\b/i + return send_list(event, hm, true) if msg =~ /missing/i + return send_list(event, false) if msg =~ /how many/i + return send_list(event) if msg =~ /\blist\b/i + return send_list(event, false, false, true) if msg =~ /how cool/i + return send_comparison(event) if msg =~ /\bcompare\b/i || msg =~ /\bcomparison\b/i + return send_stats(event) if msg =~ /\bstat/i + return send_worst(event, true) if msg =~ /\bworst\b/i + return send_worst(event, false) if msg =~ /\bbest\b/i + return send_splits(event) if msg =~ /\bsplits\b/i + return send_clean_one(event) if msg =~ /cleanliness/i + return identify(event) if msg =~ /my name is/i + return add_display_name(event) if msg =~ /my display name is/i + return set_default_palette(event) if msg =~ /my palette is/i + return set_default_mappack(event) if msg =~ /my (.*?)(map\s*)?pack (.*?)is/i + return set_default_mappacks(event) if msg =~ /use\s*default\s*(map)?\s*packs/i || msg =~ /use\s*(map)?\s*pack\s*defaults/i + return send_unique_holders(event) if msg =~ /\bunique holders\b/i + return send_twitch(event) if msg =~ /\btwitch\b/i + return add_role(event) if msg =~ /\badd\s*role\b/i + return send_aliases(event) if msg =~ /\baliases\b/i + return send_dmmc(event) if msg =~ /\bdmmc\b/i + return update_ntrace(event) if msg =~ /\bupdate\s*ntrace\b/i + return fix_episode(event) if msg =~ /fix episode/i + return faceswap(event) if msg =~ /faceswap/i + return send_mappacks(event) if msg =~ /mappacks/i + return send_denubbed(event) if msg =~ /\bdenubbed\b/i + return hello(event) if msg =~ /\bhello\b/i || msg =~ /\bhi\b/i + return thanks(event) if msg =~ /\bthank you\b/i || msg =~ /\bthanks\b/i + return send_tip(event) if msg =~/\btip\b/i + return send_convert(event) if msg =~ /\bconvert\b/i + + # If we get to this point, no command was executed + action_dec('commands') + event << "Sorry, I didn't understand your command." +end + +# Done loading file +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) diff --git a/src/models.rb b/src/models.rb new file mode 100644 index 00000000..67c47c60 --- /dev/null +++ b/src/models.rb @@ -0,0 +1,3770 @@ +# This file handles most of the internal logic of the main functions of outte +# (performing rankings, downloading scores, analyzing replays...). +# The actual output and communication is handled in messages.rb. +# +# This file also contains the main custom Modules and Classes used in the program +# (e.g. Highscoreable, Downloadable, Level, Episode, Story, Archive, Demo...). +# Including the modifications to 3rd party stuff (monkey patches). + +$load_time = Time.now + +require 'active_record' +require 'json' +require 'net/http' +require 'socket' +require 'zlib' + +# Monkey patches to get some custom behaviour from a few core classes, +# as well as ActiveRecord, Discordrb +module MonkeyPatches + def self.patch_core + # Add justification to arrays, like for strings + ::Array.class_eval do + def rjust(n, x) Array.new([0, n - length].max, x) + self end + def ljust(n, x) self + Array.new([0, n - length].max, x) end + def ^(arr) (self - arr) | (arr - self) end + end + + # Stable sorting, i.e., ensures ties maintain their order + ::Enumerable.class_eval do + def stable_sort; sort_by.with_index{ |x, idx| [ x, idx] } end + def stable_sort_by; sort_by.with_index{ |x, idx| [yield(x), idx] } end + end + + # Add bool to int casting + ::TrueClass.class_eval do def to_i; 1 end end + ::FalseClass.class_eval do def to_i; 0 end end + + # Add upwards truncation with precision + ::Numeric.class_eval do + def truncate_up(precision = 0) + factor = 10 ** -precision + n = (self.to_f / factor).ceil * factor + self.is_a?(Integer) || precision <= 0 ? n.round : n.to_f + end + end + end + + def self.patch_activerecord + # Add custom method "where_like" to Relations. Takes care of: + # - Sanitizing user input + # - Adding wildcards before and after, for substring matches + # - Executing a where query + ::ActiveRecord::QueryMethods.class_eval do + def where_like(field, str, partial: true) + return self if field.empty? || str.empty? + str = sanitize_like(str.downcase) + str = "%" + str + "%" if partial + self.where("LOWER(#{field}) LIKE (?)", str) + end + end + + # Add same method to base classes + ::ActiveRecord::Base.class_eval do + def self.where_like(field, str, partial: true) + return self if field.empty? || str.empty? + str = sanitize_like(str.downcase) + str = "%" + str + "%" if partial + self.where("LOWER(#{field}) LIKE (?)", str) + end + + # Finds exact, partial or approximate matches. Returns: + # - An object if a single match (may be approximate) is found. + # - A relation if multiple partial matches are found. + # - An array if multiple approximate matches are found. + def self.find_matches(field, str, strings = nil) + return self if field.empty? || str.empty? + matches = self.where_like(field, str) + return matches.first if matches.size == 1 + return matches if matches.size > 1 + strings = pluck(field) unless strings + matches = string_distance_list_mixed(str, strings) + return find_by(field => matches.first) if matches.size == 1 + return matches if matches.size > 1 + nil + end + end + + # Allow raw SQL queries for pluck, order, etc + [ + (::ActiveRecord::Sanitization::ClassMethods rescue nil), + (::ActiveRecord::AttributeMethods::ClassMethods rescue nil), + (::ActiveRecord::InsertAll rescue nil) + ].compact.each do |klass| + klass.class_eval do + def disallow_raw_sql!(args, permit: nil) + end + end + end + end + + # Customize Discordrb's log format to match outte's, for neatness + # Also, disable printing entire backtrace when logging exceptions + def self.patch_discordrb + ::Discordrb::Logger.class_eval do + def simple_write(stream, message, mode, thread_name, timestamp) + Log.write(message, mode[:long].downcase.to_sym, 'DRB') + end + def log_exception(e) + error("Exception: #{e.inspect}") + end + end + ::Discordrb::Webhooks::View::RowBuilder.class_eval do + def button(style:, label: nil, emoji: nil, custom_id: nil, disabled: nil, url: nil) + style = ::Discordrb::Webhooks::View::BUTTON_STYLES[style] || style + emoji = case emoji + when Integer, String + emoji.to_i.positive? ? { id: emoji } : { name: emoji } + when nil + nil + else + emoji.to_h + end + @components << { type: ::Discordrb::Webhooks::View::COMPONENT_TYPES[:button], label: label, emoji: emoji, style: style, custom_id: custom_id, disabled: disabled, url: url } + end + end + ::Discordrb::Webhooks::View::SelectMenuBuilder.class_eval do + def option(label:, value:, description: nil, emoji: nil, default: nil) + emoji = case emoji + when Integer, String + emoji.to_i.positive? ? { id: emoji } : { name: emoji } + when + nil + else + emoji.to_h + end + @options << { label: label, value: value, description: description, emoji: emoji, default: default } + end + end + end + + # Patch ChunkyPNG to add functionality and significantly optimize a few methods. + # Optimizations are now obsolete, since I'm using the corresponding methods + # in the C extension (OilyPNG) + def self.patch_chunkypng + # Faster method to render an opaque rectangle (~4x faster) + ::ChunkyPNG::Canvas::Drawing.class_eval do + def fast_rect(x0, y0, x1, y1, stroke_color = nil, fill_color = ChunkyPNG::Color::TRANSPARENT) + stroke_color = ChunkyPNG::Color.parse(stroke_color) unless stroke_color.nil? + fill_color = ChunkyPNG::Color.parse(fill_color) unless fill_color.nil? + + # Fill + if !fill_color.nil? && fill_color != ChunkyPNG::Color::TRANSPARENT + [x0, x1].min.upto([x0, x1].max) do |x| + [y0, y1].min.upto([y0, y1].max) do |y| + pixels[y * width + x] = fill_color + end + end + end + + # Stroke + if !stroke_color.nil? && stroke_color != ChunkyPNG::Color::TRANSPARENT + line(x0, y0, x0, y1, stroke_color, false) + line(x0, y1, x1, y1, stroke_color, false) + line(x1, y1, x1, y0, stroke_color, false) + line(x1, y0, x0, y0, stroke_color, false) + end + + self + end + end + + # Faster method to compose images where the pixels are either fully solid + # or fully transparent (~10x faster) + ::ChunkyPNG::Canvas::Operations.class_eval do + def fast_compose!(other, offset_x = 0, offset_y = 0, bg = 0) + check_size_constraints!(other, offset_x, offset_y) + w = other.width + o = width - w + 1 + i = offset_y * width + offset_x - o + other.pixels.each_with_index{ |color, p| + i += (p % w == 0 ? o : 1) + next if color == bg + pixels[i] = color + } + self + end + end + + ::ChunkyPNG::Canvas::Drawing.class_eval do + # For an anti-aliased line, this adjusts the shade of each of the 2 borders + # when there's a fractional line width. It also provides information to + # adjust the integer width of the line later, which may've increased + def adjust_fractional_width(w, frac_a, frac_b) + # "Odd fractional part" (distance to greatest smaller odd number) + # d in [0, 2) + wi = w.to_i + d = w - wi + (wi + 1) % 2 + + # Extra darkness to add to each border of the line + # frac_e in [0, 255] + frac_e = (128 * d).to_i + + # Compute new border shades, and specify if we rolled over + new_frac_a = (frac_a + frac_e) & 0xFF + inc_a = new_frac_a < frac_a + new_frac_b = (frac_b + frac_e) & 0xFF + inc_b = new_frac_b < frac_b + + [new_frac_a, new_frac_b, inc_a, inc_b] + end + + # Draws one chunk (set of pixels around one coordinate) of an anti-aliased line + # Allows for arbitrary widths, even fractional + def line_chunk(x, y, color = 0xFF, weight = 1, frac = 0xFF, s = 1, swap = false, antialiasing = true) + weight = 1.0 if weight < 1.0 + + # Optionally compute antialiased shades and new line width + if antialiasing + # Adjust line width and shade of line borders + frac_a, frac_b, inc_a, inc_b = adjust_fractional_width(weight, frac, 0xFF - frac) + weight = weight.to_i - (weight.to_i + 1) % 2 + + # Prepare color shades + fade_a = ChunkyPNG::Color.fade(color, frac_a) + fade_b = ChunkyPNG::Color.fade(color, frac_b) + fade_a, fade_b = fade_b, fade_a if s[1] < 0 + inc_a, inc_b = inc_b, inc_a if s[1] < 0 + + # Draw range + min = - weight / 2 - (inc_a ? 1 : 0) + max = weight / 2 + (inc_b ? 1 : 0) + else + weight = weight.to_i + fade_a = color + fade_b = color + min = - weight / 2 + 1 + max = weight / 2 + end + + # Draw + w = min + if !swap + compose_pixel(x, y + w, fade_a) + compose_pixel(x, y + w, color ) while (w += 1) < max + compose_pixel(x, y + w, fade_b) if w <= max + else + compose_pixel(y + w, x, fade_a) + compose_pixel(y + w, x, color ) while (w += 1) < max + compose_pixel(y + w, x, fade_b) if w <= max + end + end + + # Simplified version of ChunkyPNG's implementation + def line(x0, y0, x1, y1, stroke_color, inclusive = true, weight: 1, antialiasing: true) + stroke_color = ChunkyPNG::Color.parse(stroke_color) + + # Normalize coordinates + swap = (y1 - y0).abs > (x1 - x0).abs + x0, x1, y0, y1 = y0, y1, x0, x1 if swap + + # Precompute useful parameters + dx = x1 - x0 + dy = y1 - y0 + sx = dx < 0 ? -1 : 1 + sy = dy < 0 ? -1 : 1 + dx *= sx + dy *= sy + x, y = x0, y0 + s = [sx, sy] + + # If both endpoints are the same, draw a single point and return + if dx == 0 + line_chunk(x0, y0, stroke_color, weight, 0xFF, s, swap) + return self + end + + # Rotate weight + e_acc = 0 + e = ((dy << 16) / dx.to_f).round + max = dy <= 1 ? dx - 1 : dx + weight.to_i - 2 + weight *= (1 + (dy.to_f / dx) ** 2) ** 0.5 + + # Draw line chunks + 0.upto(max) do |i| + e_acc += e + y += sy if e_acc > 0xFFFF unless i == 0 && e == 0x10000 + e_acc &= 0xFFFF + w = 0xFF - (e_acc >> 8) + line_chunk(x, y, stroke_color, weight, w, s, swap, antialiasing) + x += sx + end + + self + end + end + end + + def self.apply + return if !MONKEY_PATCH + patch_core if MONKEY_PATCH_CORE + patch_activerecord if MONKEY_PATCH_ACTIVE_RECORD + patch_discordrb if MONKEY_PATCH_DISCORDRB + patch_chunkypng if MONKEY_PATCH_CHUNKYPNG + end +end + +# Common functionality for all highscoreables whose leaderboards we download from +# N++'s server (level, episode, story, userlevel). +module Downloadable + # Submit zero scores to a list of Downloadables + # event: Send msgs to Discord if not nil + def self.submit_zero_scores(list, event: nil) + ul = list.first.is_userlevel? + count = list.count + good = 0 + bad = 0 + list.find_each{ |h| + name = ul ? "userlevel #{h.id}" : h.name + res = h.submit_zero_score + if !res + bad += 1 + TmpMsg.update(event, "Failed to submit zero score to #{name} (outte++ inactive?).") unless !event + sleep(5) + elsif res.key?('rank') && !res['rank'].nil? && res['rank'].to_i >= 0 + h.update(completions: res['rank'].to_i + 1) if !h.completions || h.completions < res['rank'].to_i + 1 + h.update(submitted: true) if ul + good += 1 + dbg("Submitted zero score to #{name}: rank #{res['rank']}", progress: true) + TmpMsg.update(event, "Submitted #{good} / #{count} zero scores (#{bad} failed)...") if good % 100 == 0 && event + else + bad += 1 + TmpMsg.update(event, "Failed to submit zero score to #{name} (wrong hash?).") unless !event + end + } + end + + # Update completions for a list of Downloadables + # event: Send msgs to Discord if not nil + # retries: Retries before moving on to next level (0 = infinite) + # global: Use global boards (true), around mine (false) or default (nil) + def self.update_completions(list, event: nil, retries: 0, global: nil) + type = list.first.class.to_s.downcase + ul = list.first.is_userlevel? + count = list.count + delta = 0 + list.find_each.with_index{ |h, i| + name = ul ? "userlevel #{h.id}" : h.name + attempt = 0 + current = "#{name} [#{type} #{i} / #{count}]" + count_old = h.completions.to_i + count_new = h.update_completions(log: false, global: global) + + while !count_new + acquire_connection # In case we spend too long here, we may've disconnected + if retries == 0 || attempt < retries + TmpMsg.update(event, "Stopped updating at #{current} (waiting for outte++).") if event && attempt == 0 + attempt += 1 + sleep(5) + count_new = h.update_completions(log: false, global: global) + else + TmpMsg.update(event, "Stopped updating at #{current} (timed out waiting for outte++).") if event + return + end + end + + delta += [count_new - count_old, 0].max + TmpMsg.update(event, "Updated #{current} (Gained: #{delta})...") if event && i % 100 == 0 + } + delta + end + + def scores_uri(steam_id, qt: 0) + id_key = (is_userlevel? ? 'level' : self.class.to_s.downcase) + '_id' + npp_uri(:scores, steam_id, id_key => id, qt: qt) + end + + # Download the highscoreable's scores from Metanet's server + def get_scores(fast: false) + id_key = ("%s_id" % TYPES.keys[self.class::TYPE].downcase).to_sym + res = get_data(:scores, fast: fast, qt: 0, id_key => id) + res ? correct_ties(clean_scores(JSON.parse(res)['scores'])) : nil + end + + # Get number of completions (basically a simpler get_scores query) + # global - Use global boards rather than around mine + # log - Log download errors to the terminal and Discord + # stop - Throw exception instead of returning nil + def get_completions(global: false, log: false, stop: false) + # Fetch scores + res = Player.find_by(metanet_id: OUTTE_ID).get_scores(self, qt: global ? 0 : 1) + if res.nil? + return if !stop + perror("Unsuccessful HTTP request.", log: log, discord: log) + elsif !res + return if !stop + perror("outte++'s Steam ID not active.", log: log, discord: log) + end + + # Parse result (no result, no scores, or success) + pb = res['userInfo'] + if !pb + return -1 if !stop + perror("outte++ doesn't have a score in #{name}.", log: log, discord: log) + end + + # Return max rank between personal and whole leaderboard + # Notes: + # - Returned ranks in the sores list might be null + # - The personal rank is sometimes incorrect. For example, for stories, + # it may actually incorrectly equal the episode rank instead. + max_rank = res['scores'].map{ |s| s['rank'].to_i }.max.to_i + my_rank = pb['my_rank'].to_i + my_rank = 0 if self.class == Story && my_rank + 1 >= Episode.find_by(id: id).completions rescue 0 + [max_rank, my_rank].max + 1 + rescue => e + lex(e, "Failed to get completions for #{name}.") + nil + end + + # Sanitize received leaderboard data: + # - Reject scores by blacklisted players (hackers / cheaters) + # - Reject incorrect scores submitted accidentally by legitimate players + # - Patch score of runs submitted using old versions of the map, with different amount of gold + def clean_scores(boards) + # Compute score upper limit + if self.class == Userlevel + limit = 2 ** 32 - 1 # No limit + else + limit = TABS[self.class.to_s].map{ |k, v| v[1] }.max + TABS[self.class.to_s].each{ |k, v| if v[0].include?(self.id) then limit = v[1]; break end } + end + + # Filter out hacked runs, incorrect scores and too high scores + k = self.class.to_s.downcase.to_sym + boards.reject!{ |s| + HACKERS.key?(s['user_id']) || BLACKLIST_NAMES.include?(s['user_name']) || PATCH_IND_DEL[k].include?(s['replay_id']) || s['score'] / 1000.0 >= limit + } + + # Mark cheated runs, to be archived separately + boards.each{ |s| + s['cheated'] = true if CHEATERS.key?(s['user_id']) + } + + # Batch patch old incorrect runs + if PATCH_RUNS[k].key?(self.id) + boards.each{ |s| + entry = PATCH_RUNS[k][self.id] + s['score'] += 1000 * entry[1] if s['replay_id'] <= entry[0] + } + end + + # Individually patch old incorrect runs + boards.each{ |s| + s['score'] += 1000 * PATCH_IND_CHG[k][s['replay_id']] if PATCH_IND_CHG[k].key?(s['replay_id']) + } + + boards + rescue => e + lex(e, "Failed to clean leaderboards for #{name}") + boards + end + + # TODO: Only update scores if replay_id is new, at least for userlevel scores, to prevent overwriting + # the fields that we compute manually, such as date or fractional score. + # TODO: Keep completions up to date for userlevels, at least when there are fewer than 20 scores. + def save_scores(updated) + ActiveRecord::Base.transaction do + # Save stars so we can reassign them again later + stars = scores.where(star: true).pluck(:player_id) if self.class != Userlevel + + # Loop through all new scores + i = -1 + updated.each do |score| + # Rank field + i += 1 unless score['cheated'] + + # Precompute player and score + playerclass = self.class == Userlevel ? UserlevelPlayer : Player + player = playerclass.find_or_create_by(metanet_id: score['user_id']) + player.update(name: score['user_name'].force_encoding('UTF-8')) + scoretime = score['score'] / 1000.0 + scoretime = (scoretime * 60.0).round if self.class == Userlevel + + # Update common values + fields = { + score: scoretime, + metanet_id: score['user_id'].to_i, + replay_id: score['replay_id'].to_i, + player: player, + tied_rank: updated.reject{ |s| s['cheated'] }.find_index { |s| s['score'] == score['score'] } + } + fields.delete(:metanet_id) unless self.class == Userlevel + scores.find_or_create_by(rank: i).update(fields) unless score['cheated'] + + # Non-userlevel updates (tab, archive, demos) + next if self.class == Userlevel + scores.find_by(rank: i).update(tab: self.tab, cool: false, star: false) unless score['cheated'] + + # Create archive and demo if they don't already exist + ar = Archive.find_by(highscoreable: self, replay_id: score['replay_id']) + if !!ar + ar.update(cheated: true) if score['cheated'] + next + end + + # Update old archives + Archive.where(highscoreable: self, player: player).update_all(expired: true) + + # Create archive + ar = Archive.create( + replay_id: score['replay_id'].to_i, + player: player, + highscoreable: self, + score: (score['score'] * 60.0 / 1000.0).round, + metanet_id: score['user_id'].to_i, + date: Time.now, + tab: self.tab, + lost: false, + expired: false, + cheated: !!score['cheated'] + ) + + # Create demo + Demo.find_or_create_by(id: ar.id).update_demo + end + + # Update timestamps, cools and stars + if self.class == Userlevel + self.update( + score_update: Time.now, + scored: updated.size > 0 + ) + else + scores.where("`rank` < #{find_coolness}").update_all(cool: true) + scores.where(player_id: stars).update_all(star: true) + scores.where(rank: 0).update(star: true) + end + + # Remove scores stuck at the bottom after ignoring cheaters + # TODO: We should use the non-cheated size of updated for this + scores.where(rank: (updated.size..19).to_a).delete_all + end + end + + # Download and save the scores for the highscoreable. Returns nil in case of failure. + def update_scores(fast: false) + updated = get_scores(fast: fast) + return save_scores(updated) if updated + err("Failed to download scores for #{self.class.to_s.downcase} #{self.id}") if LOG_DOWNLOAD_ERRORS + rescue => e + lex(e, "Error updating database with #{self.class.to_s.downcase} #{self.id}: #{e}") + end + + # Update how many completions this highscoreable has, by downloading the scores + # using outte++'s N++ account, which has a score of 0.000 in all of them + def update_completions(log: false, stop: false, global: nil) + if !global.nil? + count = get_completions(global: global, log: log, stop: stop) + else + count = get_completions(global: false, log: log, stop: stop) + count = get_completions(global: true, log: log, stop: stop) if count && completions && count < completions + end + + return nil if !count + self.update(completions: count) if count > completions.to_i + completions || count + rescue => e + lex(e, "Failed to update the completions for #{name}.") + nil + end + + def submit_score(score, replays, player = nil, log: false, silent: false) + fname = verbatim(self.name.remove('`')) + + # Fetch player + player = Player.find_by(metanet_id: OUTTE_ID) if !player + if !player + err("No player to submit score to #{fname}.", discord: log) unless silent + return + end + pname = verbatim(player.name.remove('`')) + + # Construct replay data + frames = replays.map(&:size).sum + replays = replays.map{ |replay| replay.pack('C*') } + replays = self.dump_demo(replays) + replays = Zlib::Deflate.deflate(replays, 9) + + # Compute request parts + klass = self.class.to_s.downcase + klass = 'level' if klass == 'userlevel' + qt = TYPES[klass.capitalize][:qt] + score = (1000 * round_score(score)).round.to_s + version = [2842, 3009, 3096].include?(self.id) ? 1 : 2 + hash = self.map._hash(c: true, v: version) + if !hash + err("Couldn't compute #{fname} hash, not submitting #{pname} score.", discord: log) unless silent + return + end + hash = sha1(hash + score, c: true) + + parts = [ + { name: 'user_id', binary: false, value: player.metanet_id }, + { name: "#{klass}_id", binary: false, value: self.id }, + { name: 'qt', binary: false, value: qt }, + { name: 'size', binary: false, value: replays.size }, + { name: 'score', binary: false, value: score }, + { name: 'ninja_check', binary: true , value: hash }, + { name: 'replay_data', binary: true , value: replays } + ] + + # Perform HTTP POST request + res = player.send_request(:submit, parts: parts, silent: silent) + return if !res + return false if res == METANET_INVALID_RES + args = [score.to_i / 1000.0, frames, player.name, player.metanet_id, self.class.to_s.downcase, self.name, self.id] + dbg("Submitted score %.3f (%df) by %s (%d) to %s %s (%d)" % args) unless silent + JSON.parse(res) + rescue => e + lex(e, "Failed to submit score by #{pname} to #{fname}.", discord: log) unless silent + nil + end + + def submit_zero_score(log: false) + score = 0 + replay_count = TYPES[self.class.to_s][:size] rescue 1 + replays = [[]] * replay_count + player = Player.find_by(metanet_id: OUTTE_ID) + submit_score(score, replays, player, log: log) + end + + # Starting with a score of 0, submit progressively higher scores until we + # cover the entire leaderboard, so we can map it out. Ensure the last score + # we submit is just outside the top20. + # We do things slightly differently if we know we're multithreading, such as + # not setting breakpoints and not logging debug info to the terminal. + # TODO: Save directly on the db + # TODO: Update the completions field of the highscoreable + # worker - When multithreading, the ThreadPool::Thread that is running this method + def scan_boards(metanet_id: OUTTE2_ID, worker: nil) + player = Player.find_by(metanet_id: metanet_id) + data = {} + global_gap = true + holes = 0 + cur_score = 0 + max_score = (1000 * round_score(scores.minimum(:score) - 0.017)).round + cur_rank = -1 + max_rank = 0 + cur_id = -1 + wait = 10 + i = 0 + + loop do + # Add some randomized wait when multithreading to avoid hammering the server simultaneously + sleep(rand / 10) if worker + + # Submit improved score + cur_score = [cur_score, max_score].min + score = round_score(cur_score / 1000.0) + replay_count = TYPES[self.class.to_s][:size] rescue 1 + replays = [[]] * replay_count + status = submit_score(score, replays, player, log: false, silent: true) + if !status + # Submission error (commonly timeout due to bad network), or multithreading + if status.nil? || worker + worker ? worker.idle(wait) : sleep(wait) + next + end + + # Inactive user in single-thread mode + err("Failed to fetch scores (inactive Steam ID).") + quiet_breakpoint + next + end + reached_top = status['better'] == 0 && cur_score == max_score + i += 1 + + # Fetch scores around me + res = nil + begin + res = player.get_scores(self, qt: reached_top ? 0 : 1) + if res.nil? + worker ? worker.idle(wait) : sleep(wait) + raise + elsif !res + if worker + worker.idle(wait) + else + err("Failed to fetch scores (inactive Steam ID).") + quiet_breakpoint + end + raise + end + rescue + retry + end + + # Fill data + board = res['scores'] + return err("No scores found.") if board.empty? + board.each{ |s| data[s['replay_id']] = s } + (cur_id = res['userInfo']['my_replay_id']) rescue -1 + data.reject!{ |id, s| s['user_id'] == metanet_id && id != cur_id } + + # Log progress + (cur_rank = board.find{ |s| s['user_id'] == metanet_id }['rank']) rescue -1 + top_rank = board.max_by{ |s| s['rank'] }['rank'] + bot_rank = board.min_by{ |s| s['rank'] }['rank'] + max_rank = top_rank if top_rank > max_rank + if worker + worker.progress = 100.0 * (1.0 - bot_rank.to_f / (max_rank + 1)) + worker.status = "%s: %4d / %4d" % [name, data.size, max_rank + 1] + else + params = [cur_rank.ordinalize, round_score(cur_score / 1000.0), data.size, max_rank + 1, name, i, holes] + dbg("%s: %7.3f - Scanned %4d / %4d scores in %s after %3d submissions (%d holes)" % params, progress: true) + end + + # Prepare next iteration + break if reached_top + top_score = board.max_by{ |s| s['score'] }['score'] + top_score_m1 = (1000 * ((60 * top_score / 1000.0).round - 1) / 60.0).round # one less frame + top_score_p1 = (1000 * ((60 * top_score / 1000.0).round + 1) / 60.0).round # one more frame + holes += 1 if cur_score == top_score + global_gap = false if top_score_m1 > max_score + cur_score = cur_score < top_score_m1 ? top_score_m1 : cur_score < top_score ? top_score : top_score_p1 + end + ensure + # Ensure we export the data even if an exception is raised, we only get one chance at this! + data.reject!{ |id, s| s['user_id'] == metanet_id && id != cur_id } + list = correct_ties(data.values) + list.each_with_index{ |s, i| s['rank'] = i } + json = list.to_json + export = [list.size].pack('L<').b + list.map{ |s| + [s['replay_id'], (60 * s['score'] / 1000.0).round, s['user_id'], s['user_name'].delete(0.chr)].pack('L<3Z*') + }.join.b + fn = is_userlevel? ? id : name.sub(/.+?-/, tab + '-') + File.binwrite("boards/json/#{tab}/#{fn}.json", json) + File.binwrite("boards/raw/#{tab}/#{fn}", export) + + # Log progress + Log.clear + params = [cur_rank.ordinalize, round_score(cur_score / 1000.0), data.size, max_rank + 1, name, i, holes] + log_line = "%s: %7.3f - Scanned %4d / %4d scores in %s after %3d submissions (%d holes)" % params + func = data.size == max_rank + 1 ? :succ : !global_gap ? :alert : :err + if !worker + send(func, log_line) + else + mode = func == :succ ? :good : func == :alert ? :warn : :error + worker.pool.report(Log.format(log_line, mode)[:fancy]) + end + end + + # Parse exported scores from scan_boards and create corresponding RawScores + def parse_raw_scores(file, format: :json) + if !File.file?(file) + err("File #{file} not found") + return 0 + end + + # Fetch list + if format == :json + list = JSON.parse(File.read(file)) + count = list.size + else + f = File.binread(file) + count = f.unpack1('L<') + list = f.unpack('L<3Z*' * count, offset: 4).each_slice(4) + + if list.size < count + alert("Missing #{name} raw scores (#{list.size} / #{count}) in #{file}") + count = list.size + end + end + + # Init state + tied_rank = 0 + tied_score = nil + + # Iterate list + list.each_with_index do |s, i| + # Get fields + replay_id = format != :json ? s[0] : s['replay_id'] + score = format != :json ? s[1] : (60 * s['score'] / 1000.0).round + player_id = format != :json ? s[2] : s['user_id'] + name = format != :json ? s[3] : s['user_name'] + + # Compute tied rank + if !tied_score || tied_score > score + tied_rank = i + tied_score = score + end + + # Create records + RawPlayer.where(id: player_id).first_or_create(name: name) + score = RawScore.where(highscoreable_type: type_id, replay_id: replay_id) + .first_or_create( + highscoreable_id: id, + player_id: player_id, + rank: i, + tied_rank: tied_rank, + score: score + ) + + # Log + dbg("Parsed #{i + 1} / #{count} raw scores for #{self.name}", progress: true) + end + + count + end + + def correct_ties(score_hash) + score_hash.sort_by{ |s| [-s['score'], s['replay_id']] } + end + + # Return a list of all dates where the top20 changed + # We consider dates less than MAX_SECS apart to be the same + # The board is unused but necessary for compatibility with the mappack version + def changes(board = 'hs') + return [] if is_userlevel? + dates = Archive.where(highscoreable: self, cheated: false) + .order(:date) + .distinct + .pluck(:date) + dates[0..-2].each_with_index.select{ |d, i| dates[i + 1] - d > MAX_SECS } + .map(&:first).push(dates.last) + end +end + +# Common functionality for all models that have leaderboards, whether we download +# from N++'s server (Metanet campaign, userlevels) or receive them directly from +# CLE (mappacks). +module Highscoreable + def self.format_rank(rank) + rank.nil? ? '--' : rank.to_s.rjust(2, '0') + end + + # Fetches list of highscoreables with largest/smallest difference between + # the 0th and Nth scores. + # small - Whether to find smallest or largest differences + # player_id - Include only levels where the 0th is owned by player + # full - Export entire list (vs. top20 only) + # mappack - Use a mappack's boards (vs. vanilla) + # board - The mode (hs, sr), mappack-only + # frac - Compute spreads between fractional scores + def self.spreads(n, type, tabs, small = false, player_id = nil, full = false, mappack = nil, board = 'hs', frac = false) + # Sanitize parameters + n = n.clamp(0,19) + type = ensure_type(type, mappack: !mappack.nil?) + type = type.mappack if mappack + frac &&= type.include?(Levelish) + klass = mappack ? MappackScore.where(mappack: mappack) : Score + sfield = mappack ? "`score_#{board}`" : frac ? '`archives`.`score`' : '`score`' + sfield += (board == 'hs' ? ' - ' : ' + ') + '`fraction`' if frac + rfield = mappack ? "rank_#{board}" : 'rank' + tname = type.table_name + scale = mappack && board == 'hs' || !mappack && frac ? 60.0 : 1 + bench(:start) if BENCHMARK + + # Fetch list of target scores + list = klass.where(highscoreable_type: type) + .where(!tabs.empty? ? { tab: tabs } : {}) + ids = list.where(rfield => 0, player_id: player_id).pluck('`highscoreable_id`') if player_id + + # Fetch spreads + ret = list.joins(!mappack && frac ? 'INNER JOIN `archives` ON `archives`.`replay_id` = `scores`.`replay_id`' : '') + .joins("INNER JOIN `#{tname}` ON `#{tname}`.`id` = `#{klass.table_name}`.`highscoreable_id`") + .where(ids ? { highscoreable_id: ids } : {}) + .where("`#{rfield}` <= #{n}") + .group(:highscoreable_id) + .order("`spread` #{small ? 'ASC' : 'DESC'}", highscoreable_id: :asc) + .limit(full ? nil : NUM_ENTRIES) + .pluck(:highscoreable_id, :name, "(MAX(#{sfield}) - MIN(#{sfield})) / #{scale} AS `spread`") + + # Retrieve player names + pnames = klass.where(highscoreable_type: type.to_s, highscoreable_id: ret.map(&:first), rfield => 0) + .joins("INNER JOIN `players` ON `players`.`id` = `player_id`") + .pluck('`highscoreable_id`', 'IF(`display_name` IS NOT NULL, `display_name`, `name`)') + .to_h + + # Format response + ret = ret.map{ |id, level, spread| [level, spread, pnames[id]] } + bench(:step) if BENCHMARK + ret + end + + # @par player_id: Exclude levels where the player already has a score + # @par maxed: Filter and sort differently for maxes and maxables + # @par rank: Return rankings of people with most scores in maxed / maxable levels + # TODO: Change so that levels with all scores being equal also count towards being maxed, + # even if it's less than 20 due to cheaters. Ditto for maxables. + def self.ties(type, tabs, player_id = nil, maxed = false, rank = false, mappack = nil, board = 'hs') + type = ensure_type(type) + bench(:start) if BENCHMARK + + # Prepare params + type = type.mappack if mappack + table = type.table_name + rfield = (!mappack ? 'rank' : "rank_#{board}").to_sym + trfield = (!mappack ? 'tied_rank' : "tied_rank_#{board}").to_sym + + # Retrieve highscoreables with more ties for 0th + klass = mappack ? MappackScore : Score + ret = !tabs.empty? ? klass.where(tab: tabs) : klass + ret = ret.where(mappack: mappack) if mappack + ret = ret.joins("INNER JOIN `#{table}` ON `#{table}`.`id` = `highscoreable_id`") + .where(highscoreable_type: type, trfield => 0) + .group(:highscoreable_id) + .order(!maxed || mappack ? 'COUNT(`highscoreable_id`) DESC' : '', :highscoreable_id) + .having("COUNT(`highscoreable_id`) >= #{MIN_TIES}") + .having(!player_id.nil? ? 'amount = 0' : '') + .pluck('`highscoreable_id`', 'COUNT(`highscoreable_id`)', '`name`', !player_id.nil? ? "COUNT(IF(`player_id` = #{player_id}, `player_id`, NULL)) AS `amount`" : '1') + .map{ |a, b, c| [a, [b, c]] } + .to_h + + # Retrieve score counts for each highscoreable + counts = klass.where(highscoreable_type: type, highscoreable_id: ret.keys) + .group(:highscoreable_id) + .order('COUNT(`id`) DESC') + .count(:id) unless mappack + + # Filter highscoreables + maxed ? ret.select!{ |id, arr| arr[0] >= (!mappack ? counts[id] : 20) } : ret.select!{ |id, arr| arr[0] < (!mappack ? counts[id] : 20) } if !maxed.nil? + + if rank + # Return only IDs, for the rankings + ret = ret.keys + else + # Fetch player names owning the 0ths on said highscoreables + names = klass.where(highscoreable_type: type, highscoreable_id: ret.keys, rfield => 0) + .joins("INNER JOIN `players` ON `players`.`id` = `player_id`") + .pluck('`highscoreable_id`', 'IF(`display_name` IS NOT NULL, `display_name`, `name`)') + .to_h + + # Format response + ret = ret.map{ |id, arr| [arr[1], arr[0], names[id]] } + end + bench(:step) if BENCHMARK + ret + rescue => e + lex(e, 'Failed to compute maxables or maxes') + + end + + # Returns episodes or stories sorted by cleanliness + def self.cleanliness(type, tabs, rank = 0, mappack = nil, board = 'hs') + # Integrity checks + raise "Attempted to compute cleanliness of level" if type.include?(Levelish) + raise "Attempted to compute non-hs/sr cleanliness" if !['hs', 'sr'].include?(board) + + # Setup params + type = type.to_s + table = type.downcase.pluralize + table = table.prepend('mappack_') if mappack + count = type == 'Episode' ? 5 : 25 + type = type.prepend('Mappack') if mappack + rfield = !mappack ? 'rank' : "rank_#{board}" + sfield = !mappack ? 'score' : "score_#{board}" + sfield += " / 60" if mappack && board == 'hs' + offset = board == 'hs' ? 90 * (count - 1) : 0 + + # Begin queries + bench(:start) if BENCHMARK + query = !mappack ? Score : MappackScore.where(mappack: mappack) + query = !tabs.empty? ? query.where(tab: tabs) : query + + # Fetch level 0th sums + lvls = query.where(highscoreable_type: mappack ? 'MappackLevel' : 'Level', rfield => 0) + .joins("INNER JOIN `#{table}` ON `#{table}`.`id` = `highscoreable_id` DIV #{count}") + .group("`highscoreable_id` DIV #{count}") + .sum(sfield) + + # Fetch episode/story 0th scores and compute cleanliness + ret = query.where(highscoreable_type: type, rfield => rank) + .joins("INNER JOIN `#{table}` ON `#{table}`.`id` = `highscoreable_id`") + .joins('INNER JOIN `players` ON `players`.`id` = `player_id`') + .pluck("`#{table}`.`id`", "`#{table}`.`name`", sfield, 'IF(`display_name` IS NULL, `players`.`name`, `display_name`)') + .map{ |id, e, s, p| [e, round_score((lvls[id] - s).abs - offset), p] } + bench(:step) if BENCHMARK + ret + rescue => e + lex(e, 'Failed to compute cleanlinesses') + nil + end + + def is_mappack? + self.is_a?(MappackHighscoreable) + end + + def is_userlevel? + self.is_a?(Userlevel) + end + + def is_vanilla? + !is_mappack? && !is_userlevel? + end + + def is_level? + self.is_a?(Levelish) + end + + def is_episode? + self.is_a?(Episodish) + end + + def is_story? + self.is_a?(Storyish) + end + + def is_lotd? + GlobalProperty.get_current(self.class.vanilla, is_mappack?) == self + end + + def type_id + case self + when Levelish + TYPE_LEVEL + when Episodish + TYPE_EPISODE + when Storyish + TYPE_STORY + end + end + + # Protected levels have secret replays + def is_protected? + return false if self.class == Userlevel + code = map.mappack.code + name = map.name[4..-1] + PROTECTED_BOARDS.key?(code) && PROTECTED_BOARDS[code].include?(name) + end + + # Private levels have secret scores (and replays) + def is_private? + return false if self.class == Userlevel + return true if is_protected? + code = map.mappack.code + name = map.name[4..-1] + PRIVATE_BOARDS.key?(code) && PRIVATE_BOARDS[code].include?(name) + end + + # Arguments are unused, but they're necessary to be compatible with the corresponding + # function in MappackHighscoreable + def leaderboard(*args, **kwargs) + return Archive.scores(self, kwargs[:date], full: kwargs[:truncate] == 0, **kwargs) if kwargs[:date] + return scores if kwargs[:pluck] == false # not nil! + ul = is_userlevel? + attr_names = %W[rank id score name metanet_id replay_id] + attrs = [ + 'rank', + "#{ul ? 'userlevel_' : ''}scores.id", + 'score', + ul ? 'name' : 'IF(`display_name` IS NOT NULL, `display_name`, `name`)', + 'metanet_id', + 'replay_id' + ] + attr_names << 'cool' << 'star' if !ul + attrs << 'cool' << 'star' if !ul + pclass = ul ? 'userlevel_players' : 'players' + scores.joins("INNER JOIN `#{pclass}` ON `#{pclass}`.`id` = `player_id`") + .pluck(*attrs).map{ |s| attr_names.zip(s).to_h } + end + + # Format a single leaderboard for printing + def format_scores_single(board = 'hs', np: 0, sp: 0, ranks: 20.times.to_a, full: false, cools: true, stars: true, cheated: false, frac: false, dev: true, prec: -1, flags: true, date: nil) + # Reload scores, otherwise sometimes recent changes aren't in memory + scores.reload + boards = leaderboard(board, pluck: true, aliases: true, truncate: full ? 0 : 20, frac: frac && !is_vanilla?, cheated: cheated, date: date, cools: cools, stars: stars).each_with_index.select{ |_, r| + full ? true : ranks.include?(r) + }.sort_by{ |_, r| full ? r : ranks.index(r) } + sfield = !is_mappack? ? 'score' : "score_#{board}" + rfield = !is_mappack? ? 'replay_id' : 'id' + + # If we have a vanilla highscoreable and we don't provide a date then we're using + # the scores table. Otherwise we're using the archives table or the mappack_scores table. + vanilla_table = is_vanilla? && !date + + # Figure out if cheated scores need to be inserted (they already have if we provided a date) + if SHOW_CHEATERS && cheated && vanilla_table + cheated_scores = Archive.where(highscoreable: self, expired: false, cheated: true) + .joins("INNER JOIN `players` ON `players`.`metanet_id` = `archives`.`metanet_id`") + .order('`score` DESC', '`replay_id` ASC') + .pluck(:name, '`score` / 60.0', :metanet_id, :replay_id) + cheated_scores.map!{ |s| + h = {} + h[sfield] = s[1].to_f + h['name'] = s[0] + h['metanet_id'] = s[2] + h[rfield] = s[3] + h['cool'] = false + h['star'] = false + h['cheated'] = true + [h, -1] + } + boards += cheated_scores + boards.sort_by!{ |s, _| [-(s[sfield] * 60).round, s[rfield]] } + end + + # Scale scores stored as frame counts + scale = is_mappack? && board == 'hs' || is_userlevel? || is_vanilla? && !!date + boards.each{ |s, _| s[sfield] /= 60.0 } if scale + + # Figure out if interpolated fractional scores need to be used instead + frac &&= is_level? && ['hs', 'sr'].include?(board) + equals = [] + if frac + if vanilla_table + boards.each{ |s, _| s[sfield] = round_score(s[sfield]) } # Normalize scores + fractions = Archive.where(highscoreable: self).pluck(:replay_id, :fraction).to_h + end + cools = false + boards.each{ |s, _| + fraction = vanilla_table ? fractions[s[rfield]] : s['fraction'] + s['question'] = true if fraction == 1 + if board == 'hs' + s[sfield] -= fraction / 60.0 + elsif board == 'sr' + s[sfield] += fraction + end + } + boards.sort_by!{ |s, _| [board == 'hs' ? -s[sfield] : s[sfield], s[rfield]] } + equals = boards.group_by{ |s, _| s[sfield] } + .select{ |_, list| list.size > 1 } + .map{ |_, list| list.map{ |s, _| s[rfield] } } + .flatten + end + + # Insert DEV scores if required + if dev && is_mappack? && (dev_score = board == 'hs' ? dev_hs : dev_sr) + index = boards.index{ |s, _| + board == 'hs' ? s[sfield] <= dev_score : s[sfield] >= dev_score + } + score = { + sfield => round_score(dev_score), + 'name' => DEV_PLAYER_NAME, + 'metanet_id' => -1, + rfield => -1, + 'cool' => false, + 'star' => false, + 'fraction' => 0, + 'cheated' => false, + 'dev' => true + } + boards.insert(index || -1, [score, -1]) + end + + # Calculate padding + float = (!is_mappack? || board == 'hs' || frac) && prec != 0 + name_padding = np > 0 ? np : boards.map{ |s, _| s['name'].to_s.length }.max + score_padding = sp > 0 ? sp : boards.map{ |s, _| s[sfield] }.max.to_i.to_s.length + score_padding += float ? (prec > 0 ? prec : frac && board == 'hs' ? 6 : 3) + 1 : 0 + + # Print scores + color = !full || boards.size <= 20 + rank = -1 + boards.map{ |s, r| + rank += 1 if !s['cheated'] && !s['dev'] + Scorish.format( + name_padding, score_padding, cools: cools, stars: stars, mode: board, + t_rank: rank, mappack: is_mappack?, userlevel: is_userlevel?, h: s, + frac: frac, equal: equals.include?(s[rfield]), prec: prec, flags: flags, + color: !color ? nil : s['cheated'] ? ANSI.red : s['dev'] ? ANSI.blue : nil + ) + } + end + + # Format a dual leaderboard for printing + def format_scores_dual(np: 0, sp: 0, ranks: 20.times.to_a, full: false, cools: true, stars: true, cheated: false, frac: false, dev: true, prec: -1, flags: true, date: nil) + # Fetch scores + board_hs = format_scores_single('hs', np: np, sp: sp, ranks: ranks, full: full, cools: cools, stars: stars, cheated: cheated, frac: frac, dev: dev, prec: prec, flags: false, date: date) + board_sr = format_scores_single('sr', np: np, sp: sp, ranks: ranks, full: full, cools: cools, stars: stars, cheated: cheated, frac: frac, dev: dev, prec: prec, flags: false, date: date) + size = [board_hs.size, board_sr.size].max + return [] if size == 0 + + # Pad each board for alignment + length_hs = board_hs.first.length rescue 0 + length_sr = board_sr.first.length rescue 0 + board_hs = board_hs.ljust(size, ' ' * length_hs) + board_sr = board_sr.ljust(size, ' ' * length_sr) + + # Format boards + header = ' ' + 'Highscore'.center(length_hs - 4) + ' ' + 'Speedrun'.center(length_sr - 4) + [header, *board_hs.zip(board_sr).map{ |hs, sr| hs.sub(':', ' │') + ' │ ' + sr[4..-1] }] + end + + # Format a leaderboard for printing + def format_scores(np: 0, sp: 0, mode: 'hs', ranks: 20.times.to_a, join: true, full: false, cools: true, stars: true, cheated: false, frac: false, dev: true, prec: -1, flags: true, date: nil) + ret = if !is_mappack? || mode != 'dual' + format_scores_single(mode, np: np, sp: sp, ranks: ranks, full: full, cools: cools, stars: stars, cheated: cheated, frac: frac, dev: dev, prec: prec, flags: flags, date: date) + else + format_scores_dual(np: np, sp: sp, ranks: ranks, full: full, cools: cools, stars: stars, cheated: cheated, frac: frac, dev: dev, prec: prec, flags: flags, date: date) + end + ret = ["This #{self.class.to_s.remove('Mappack').downcase} has no scores!"] if ret.size == 0 + ret = ret.join("\n") if join + ret + end + + # Given a date, compute the differences between the current leaderboards and + # the leaderboards on that date. + def difference(date, board = 'hs') + rfield = is_mappack? ? "rank_#{board}" : 'rank' + sfield = is_mappack? ? "score_#{board}" : 'score' + old_scale = !is_mappack? || board == 'hs' ? 60.0 : 1 + cur_scale = is_mappack? && board == 'hs' ? 60.0 : 1 + + # Fetch old and current leaderboards + if is_mappack? + old_boards = leaderboard(board, truncate: 0, pluck: true, date: date) + else + old_boards = Archive.scores(self, date.to_i, pluck: true) + end + cur_boards = leaderboard(board, pluck: false) + + # Compute differences + cur_boards.map do |cur_score| + old_score = old_boards.each_with_index.find{ |s, i| + s['player_id'] == cur_score.player_id + } + change = { + rank: old_score[1] - cur_score[rfield], + score: cur_score[sfield] / cur_scale - old_score[0][sfield] / old_scale + } if old_score + { score: cur_score, change: change } + end + end + + # Given the changes in a leaderboard, format the suffix for each line, containing + # the change in rank and score, with colors if RICH_DIFFS is true. + # c - The change for this player, in the format given by "difference" + # diff - The output string, modified in place + # diff_score - Include score change in the suffix (not used for dual boards) + # pad_rank - Length of rank change number + # pad_change - Length of score change number + # inc - Whether higher scores are better, for coloring + # TODO: Remove trailing whitespace + def self.format_diff_change(c, diff, diff_score, pad_rank, pad_change, inc = true) + # Score is brand new + if !c + diff << ANSI.alert if RICH_DIFFS && diff_score + diff << 'NEW' + diff << ' ' * (pad_change + 2) if diff_score + diff << ANSI.reset if RICH_DIFFS + return ANSI.alert + end + + # Nothing changed (neither rank nor score) + if c[:score].abs < 0.01 && c[:rank] == 0 + diff << '━' + diff << ' ' * pad_rank + diff << ' ' * (pad_change + 2) if diff_score + return ANSI.none + end + + # Determine score and rank colors + better = inc && c[:score] > 0.01 || !inc && c[:score] < -0.01 + worse = inc && c[:score] < -0.01 || !inc && c[:score] > 0.01 + score_color = better ? ANSI.good : worse ? ANSI.bad : ANSI.none + rank_color = c[:rank] > 0 ? ANSI.good : c[:rank] < 0 ? ANSI.bad : ANSI.none + + # Format rank change + needs_color = diff_score && rank_color != ANSI.none || !diff_score && score_color != rank_color + rank = "━▲▼"[c[:rank] <=> 0] + rank << (c[:rank] != 0 ? "%-#{pad_rank}d" % [c[:rank].abs] : ' ' * pad_rank) + diff << rank_color if RICH_DIFFS && needs_color + diff << rank + diff << ANSI.reset if RICH_DIFFS && rank_color != ANSI.none && !diff_score + return score_color unless diff_score + + # Format score change (optional, we don't use it in dual boards) + fmt = c[:score].is_a?(Integer) ? 'd' : '.3f' + score = "(%+#{pad_change + 1}#{fmt})" % c[:score] + changed = better || worse + diff << score_color if RICH_DIFFS && score_color != rank_color + diff << (changed ? ' ' + score : ' ' * (pad_change + 2)) + diff << ANSI.reset if RICH_DIFFS && score_color != ANSI.none + + score_color + end + + # Format Top20 changes between the current boards and 'date' for a single board (e.g. hs / sr) + # Returns an array of strings, one per line / rank + # diff_score : Show score changes + # empty : Return an empty array if there are no differences + def format_difference_board(date, board = 'hs', diff_score: true, empty: true, pad_end: false) + difffs = difference(date, board) + return [] if empty && difffs.all?{ |d| !d[:change].nil? && d[:change][:score].abs < 0.01 && d[:change][:rank] == 0 } + + sfield = is_mappack? ? "score_#{board}" : 'score' + scale = is_mappack? && board == 'hs' ? 60.0 : 1 + offset = is_mappack? && board == 'sr' ? 0 : 4 + + pad_name = difffs.map{ |o| o[:score].player.print_name.length }.max + pad_score = difffs.map{ |o| (o[:score][sfield] / scale).abs.to_i }.max.to_s.length + offset + pad_rank = [difffs.map{ |d| d[:change] }.compact.map{ |c| c[:rank].abs.to_i }.max.to_s.length, 2].max + pad_change = difffs.map{ |d| d[:change] }.compact.map{ |c| c[:score].abs.to_i }.max.to_s.length + offset + pad_rank = 0 if !pad_end && !diff_score + pad_change = 0 if !pad_end + + difffs.each_with_index.map{ |o, i| + diff = '' + score = o[:score].format(pad_name, pad_score, false, board, i) + color = Highscoreable.format_diff_change(o[:change], diff, diff_score, pad_rank, pad_change, board != 'sr') + score.gsub!(/(\S+)$/, color + '\1') if RICH_DIFFS && !diff_score && color != ANSI.none + "#{score} #{diff}" + } + rescue => e + lex(e, 'Formatting leaderboard differences') + nil + end + + # Format Top20 changes between the current boards and the boards on 'date' + # Returns a string, empty if no differences. Returns nil if error. + def format_difference(date, board = 'hs') + # Differences for a single board + return format_difference_board(date, board).join("\n") if !is_mappack? || board != 'dual' + + # Construct differences for dual boards (hs and sr) + diffs_hs = format_difference_board(date, 'hs', diff_score: false, empty: false, pad_end: true) + diffs_sr = format_difference_board(date, 'sr', diff_score: false, empty: false) + + # Return empty string if there are no differences in hs nor sr + empty_hs = diffs_hs.count{ |d| d.strip[-1] == '━' && d.scan(/\x1B\[\d+m/).size == 0 } == diffs_hs.size + empty_sr = diffs_sr.count{ |d| d.strip[-1] == '━' && d.scan(/\x1B\[\d+m/).size == 0 } == diffs_sr.size + return '' if diffs_hs.empty? && diffs_sr.empty? || empty_hs && empty_sr + + # Pad both boards to the same size (they should already be!) + length_hs = diffs_hs.first.gsub(/\x1B\[\d+m/, '').length rescue 0 + length_sr = diffs_sr.first.gsub(/\x1B\[\d+m/, '').length rescue 0 + size = [diffs_hs.size, diffs_sr.size].max + diffs_hs = diffs_hs.ljust(size, ' ' * length_hs) + diffs_sr = diffs_sr.ljust(size, ' ' * length_sr) + + # Format header and list + header = ' ' + 'Highscore'.center(length_hs - 4) + ' ' + 'Speedrun'.center(length_sr - 4) + ret = [header, *diffs_hs.zip(diffs_sr).map{ |hs, sr| hs.sub(':', ' │') + ' │ ' + sr[4..-1] }] + ret.join("\n") + rescue => e + lex(e, 'Formatting leaderboard differences') + nil + end + + # Format the message for Top20 changes, header included + def format_difference_message(diff, date, past: false, explicit: false) + # Format highscoreable reference + if explicit + name = format_name + else + article = past ? 'last' : 'this' + period = is_level? ? 'day' : is_episode? ? 'week' : 'month' + since = is_level? ? (past ? 'yesterday' : 'today') : is_episode? ? "#{article} week" : "#{article} month" + type = is_story? ? 'column' : self.class.to_s.downcase.remove('mappack') + mappack = is_mappack? ? self.mappack.code.upcase : '' + name = "#{since}'s #{mappack} #{type} of the #{period}, #{format_name}," + end + span = format_timestamp(date, date: true, long: true) + + # Format header and message + changes = "top20 changes on #{name} since #{span}" + return "Failed to calculate #{changes}.".squish if diff.nil? + return "There #{past ? 'were' : 'have been'} no #{changes} :(".squish if diff.strip.empty? + format_header(changes) + format_block(diff) + end + + # Computes the amount of cool scores on the board + # TODO: Support frac + def find_coolness(scores: nil, date: nil, board: 'hs') + # Fetch list of scores + scores = scores ? scores.map(&:dup) : leaderboard(board, aliases: true, date: date) + return 0 if scores.empty? + hs = board == 'hs' + sfield = !is_mappack? ? 'score' : "score_#{board}" + if hs + scores.each{ |s| s[sfield] /= 60.0 } if is_mappack? || date + scores.each{ |s| s[sfield] = round_score(s[sfield]) } + end + + # Compare string scores and find first difference + max = scores.max_by{ |s| s[sfield] }[sfield].to_i.to_s.length + max += 4 if hs + fmt = hs ? '%0*.3f' : '%0*d' + s1 = fmt % [max, scores.first[sfield]] + s2 = fmt % [max, scores.last[sfield]] + d = (0...max).find{ |i| s1[i] != s2[i] } + return 0 if !d + + # Count scores before first difference + d = hs ? -(max - d - 5) - (max - d < 4 ? 1 : 0) : -(max - d - 1) + threshold = scores[0][sfield].truncate(d) + threshold += 10 ** -d if !hs + scores.index{ |s| hs ? s[sfield] < threshold : s[sfield] >= threshold } || 0 + end + + # The next function navigates through highscoreables. + # @par1: Offset (1 = next, -1 = prev, 2 = next tab, -2 = prev tab). + # @par2: Enable tab change with +-1, otherwise clamp to current tab + # + # Note: + # We deal with edge cases separately because we change the natural order + # of tabs, so the ID is not always what we want (the internal order of + # tabs is SI, S, SL, ?, SU, !, but we want SI, S, SU, SL, ?, !, as it + # appears in the game). + def nav(c, tab: true) + klass = self.class.to_s.remove("Mappack") + short = klass != 'Level' + mode = self.mode rescue 0 + tabs = TABS_NEW.select{ |k, v| v[:mode] == mode && (short ? !v[:secret] : true) } + .sort_by{ |k, v| v[:index] }.to_h + i = tabs.keys.index(self.tab.to_sym) + tabs = tabs.values + old_id = is_mappack? ? inner_id : id + + # Scale factor to translate Level IDs to Episode / Story IDs + type = TYPES[klass] + fo = 5 ** type[:id] + offset = old_id - tabs[i][:start] / fo + + case c + when 1 + new_tab = tabs[(i + 1) % tabs.size] + fs = type[:id] == 2 && tabs[i][:x] ? 30 : fo + if old_id < tabs[i][:start] / fo + tabs[i][:size] / fs - 1 + new_id = old_id + 1 + else + new_id = tab ? new_tab[:start] / fo : old_id + end + when -1 + new_tab = tabs[(i - 1) % tabs.size] + fs = type[:id] == 2 && new_tab[:x] ? 30 : fo + if old_id > tabs[i][:start] / fo + new_id = old_id - 1 + else + new_id = tab ? new_tab[:start] / fo + new_tab[:size] / fs - 1 : old_id + end + when 2 + new_tab = tabs[(i + 1) % tabs.size] + fs = type[:id] == 2 && new_tab[:x] ? 30 : fo + new_id = new_tab[:start] / fo + offset.clamp(0, new_tab[:size] / fs - 1) + when -2 + new_tab = tabs[(i - 1) % tabs.size] + fs = type[:id] == 2 && new_tab[:x] ? 30 : fo + new_id = new_tab[:start] / fo + offset.clamp(0, new_tab[:size] / fs - 1) + else + new_id = old_id + end + + new_id += type[:slots] * mappack.id if is_mappack? + self.class.find(new_id) rescue self + rescue => e + lex(e, 'Failed to navigate highscoreable') + self + end + + # Shorcuts for the above + def next_h(**args) + nav(1, **args) + end + + def prev_h(**args) + nav(-1, **args) + end + + def next_t(**args) + nav(2, **args) + end + + def prev_t(**args) + nav(-2, **args) + end +end + +# Implemented by Level, MappackLevel and Userlevel +module Levelish + TYPE = TYPE_LEVEL + + # Return the Map object (containing map data), if it exists + def map + self.is_a?(Level) ? MappackLevel.find_by(id: id) : self + end + + # Return the corresponding Level object of a MappackLevel + # 'null' determines whether to return nil or self if no equivalent exists + def vanilla(null = false) + return self if self.is_a?(Level) || is_userlevel? + level = Level.find_by(id: id) + return level ? level : null ? nil : self + end + + def story + self.episode.story + end + + def levels + [self] + end + + def format_name(bold: false) + bd = bold ? '**' : '' + author_name = author || userlevel&.author&.name if is_mappack? + str = "#{verbatim(longname)} (#{bd}#{name.remove('MET-')}#{bd})" + str += " by #{verbatim(author_name)}" if author_name + str + end + + def format_challenges + pad = challenges.map{ |c| c.count }.max + challenges.map{ |c| c.format(true, pad) }.join("\n") + end + + # Dump demo header for communications with N++ + def demo_header(framecount) + # Precompute some values + m = mode.to_i + n = m == 1 ? 2 : 1 + framecount /= n + size = framecount * n + 26 + 4 * n + h_id = is_mappack? ? inner_id : id + + # Build header + header = [0].pack('C') # Type (0 lvl, 1 lvl in ep, 2 lvl in sty) + header << [size].pack('L<') # Data length + header << [1].pack('L<') # Replay version + header << [framecount].pack('L<') # Data size in bytes + header << [h_id].pack('L<') # Level ID + header << [m].pack('L<') # Mode (0-2) + header << [0].pack('L<') # ? + header << (m == 1 ? "\x03" : "\x01") # Ninja mask (1,3) + header << [-1, -1].pack("l<#{n}") # ? + + # Return + header + end + + # Dumps the level's demo in the format N++ uses for server communications + def dump_demo(demos) + demo_header(demos[0].size) + demos[0] + end +end + +class Level < ActiveRecord::Base + include Downloadable + include Highscoreable + include Levelish + has_many :scores, ->{ order(:rank) }, as: :highscoreable + has_many :videos, as: :highscoreable + has_many :challenges + has_many :level_aliases + belongs_to :episode + create_enum(:tab, TABS_NEW.map{ |k, v| [k, v[:mode] * 7 + v[:tab]] }.to_h) + + def self.mappack + MappackLevel + end + + def self.vanilla + Level + end + + def add_alias(a) + LevelAlias.find_or_create_by(level: self, alias: a) + end +end + +# Implemented by Episode and MappackEpisode +module Episodish + TYPE = TYPE_EPISODE + + # Return the Map object (containing map data), if it exists + def map + self.is_a?(Episode) ? MappackEpisode.find_by(id: id) : self + end + + def vanilla(null = false) + return self if self.is_a?(Episode) + episode = Episode.find_by(id: id) + return episode ? episode : null ? nil : self + end + + def is_eotw? + GlobalProperty.get_current(Episode, is_mappack?) == self + end + + def format_name(bold: false) + bd = bold ? '**' : '' + "#{bd}#{name.remove('MET-')}#{bd}" + end + + def cleanliness(rank = 0, board = 'hs') + klass = !is_mappack? ? Score : MappackScore + rfield = (!is_mappack? ? 'rank' : "rank_#{board}").to_sym + sfield = (!is_mappack? ? 'score' : "score_#{board}").to_sym + scale = is_mappack? && board == 'hs' ? 60.0 : 1.0 + offset = !is_mappack? || board == 'hs' ? 4 * 90.0 : 0.0 + + level_scores = klass.where(highscoreable: levels, rfield => 0).sum(sfield) rescue nil + episode_score = scores.find_by(rfield => rank)[sfield] rescue nil + return nil if level_scores.nil? || episode_score.nil? + diff = (level_scores - episode_score).abs / scale - offset + diff = diff.to_i if is_mappack? && board != 'hs' + + diff + rescue => e + lex(e, "Failed to compute cleanliness of episode #{self.name}") + nil + end + + def ownage + owner = scores[0].player + lvls = Score.where(highscoreable: levels, rank: 0).count("if(player_id = #{owner.id}, 1, NULL)") + [name, lvls == 5, owner.name] + end + + def splits(rank = 0, board: 'hs', frac: false) + sfield = !is_mappack? ? 'score' : "score_#{board}" + start = is_mappack? && board == 'sr' ? 0 : 90.0 + factor = is_mappack? && board == 'hs' ? 60.0 : 1 + offset = !is_mappack? || board == 'hs' ? 90.0 : 0 + scores = levels.map{ |l| l.leaderboard(board, frac: frac)[rank][sfield] } + splits_from_scores(scores, start: start, factor: factor, offset: offset, frac: frac) + rescue => e + lex(e, 'Failed to compute splits') + nil + end + + # Header of an episode demo: + # 4B - Magic number (0xffc0038e) + # 20B - Block length for each level demo (5 * 4B) + def demo_header(framecounts) + header_size = 26 + 4 * (mode == 1 ? 2 : 1) + replay = [MAGIC_EPISODE_VALUE].pack('L<') + replay << framecounts.map{ |f| f + header_size }.pack('L<5') + end + + # Dumps the episodes's demo in the format N++ uses for server communications + def dump_demo(demos) + replay = demo_header(demos.map(&:size)) + levels.each_with_index{ |l, i| + replay << l.demo_header(demos[i].size) + replay << demos[i] + } + replay + end +end + +class Episode < ActiveRecord::Base + include Downloadable + include Highscoreable + include Episodish + has_many :scores, ->{ order(:rank) }, as: :highscoreable + has_many :videos, as: :highscoreable + has_many :levels + belongs_to :story + create_enum(:tab, TABS_NEW.map{ |k, v| [k, v[:mode] * 7 + v[:tab]] }.to_h) + + def self.mappack + MappackEpisode + end + + def self.vanilla + Episode + end + + def self.ownages(tabs) + bench(:start) if BENCHMARK + query = !tabs.empty? ? Score.where(tab: tabs) : Score + # retrieve episodes with all 5 levels owned by the same person + epis = query.where(highscoreable_type: 'Level', rank: 0) + .joins('INNER JOIN levels ON levels.id = scores.highscoreable_id') + .group('levels.episode_id') + .having('cnt = 1') + .pluck('levels.episode_id', 'MIN(scores.player_id)', 'COUNT(DISTINCT scores.player_id) AS cnt') + .map{ |e, p, c| [e, p] } + .to_h + # retrieve respective episode 0ths + zeroes = query.where(highscoreable_type: 'Episode', highscoreable_id: epis.keys, rank: 0) + .joins('INNER JOIN players ON players.id = scores.player_id') + .pluck('scores.highscoreable_id', 'players.id') + .to_h + # retrieve episode names + enames = Episode.where(id: epis.keys) + .pluck(:id, :name) + .to_h + # retrieve player names + pnames = Player.where(id: epis.values) + .pluck(:id, :name, :display_name) + .map{ |a, b, c| [a, [b, c]] } + .to_h + # keep only matches between the previous 2 result sets to obtain true ownages + ret = epis.reject{ |e, p| p != zeroes[e] } + .sort_by{ |e, p| e } + .map{ |e, p| [enames[e], pnames[p][1].nil? ? pnames[p][0] : pnames[p][1]] } + bench(:step) if BENCHMARK + ret + end +end + +# Implemented by Story and MappackStory +module Storyish + TYPE = TYPE_STORY + + # Return the Map object (containing map data), if it exists + def map + self.is_a?(Story) ? MappackStory.find_by(id: id) : self + end + + def vanilla(null = false) + return self if self.is_a?(Story) + story = Story.find_by(id: id) + return story ? story : null ? nil : self + end + + def is_cotm? + GlobalProperty.get_current(Story, is_mappack?) == self + end + + def format_name(bold: false) + bd = bold ? '**' : '' + "#{bd}#{name.remove('MET-')}#{bd}" + end + + def levels + (is_mappack? ? MappackLevel : Level).where("id DIV 25 = #{id}").order(:id) + end + + def cleanliness(rank = 0, board = 'hs') + klass = !is_mappack? ? Score : MappackScore + rfield = (!is_mappack? ? 'rank' : "rank_#{board}").to_sym + sfield = (!is_mappack? ? 'score' : "score_#{board}").to_sym + scale = is_mappack? && board == 'hs' ? 60.0 : 1.0 + offset = !is_mappack? || board == 'hs' ? 24 * 90.0 : 0.0 + + level_scores = klass.where(highscoreable: levels, rfield => 0).sum(sfield) rescue nil + story_score = scores.find_by(rfield => rank)[sfield] rescue nil + return nil if level_scores.nil? || story_score.nil? + diff = (level_scores - story_score).abs / scale - offset + diff = diff.to_i if is_mappack? && board != 'hs' + + diff + rescue => e + lex(e, "Failed to compute cleanliness of episode #{self.name}") + nil + end + + # Header of a story demo: + # 4B - Magic number (0xff3800ce) + # 4B - Demo data block total size + # 100B - Block length for each level demo (25 * 4B) + def demo_header(framecounts) + header_size = 26 + 4 * (mode == 1 ? 2 : 1) + replay = [MAGIC_STORY_VALUE].pack('L<') + replay << [framecounts.sum + 25 * header_size].pack('L<') + replay << framecounts.map{ |f| f + header_size }.pack('L<25') + end + + # Dumps the story's demo in the format N++ uses for server communications + def dump_demo(demos) + replay = demo_header(demos.map(&:size)) + episodes.each_with_index{ |e, j| + e.levels.each_with_index{ |l, i| + replay << l.demo_header(demos[5 * j + i].size) + replay << demos[5 * j + i] + } + } + replay + end +end + +class Story < ActiveRecord::Base + include Downloadable + include Highscoreable + include Storyish + has_many :scores, ->{ order(:rank) }, as: :highscoreable + has_many :videos, as: :highscoreable + has_many :episodes + create_enum(:tab, TABS_NEW.map{ |k, v| [k, v[:mode] * 7 + v[:tab]] }.to_h) + + def self.mappack + MappackStory + end + + def self.vanilla + Story + end +end + +# Implemented by Score and MappackScore +module Scorish + + def self.format( + name_pad = DEFAULT_PADDING, + score_pad = 0, + cools: true, + stars: true, + mode: 'hs', + t_rank: nil, + mappack: false, + userlevel: false, + h: {}, + frac: false, + equal: false, + prec: -1, + flags: true, + color: nil + ) + mode = 'hs' if mode.nil? + hs = mode == 'hs' + cheat = !!h['cheated'] + dev = !!h['dev'] + decimals = (!mappack || hs || frac) && prec != 0 ? (prec > 0 ? prec : hs && frac ? 6 : 3) : 0 + + # Compose each element + t_star = userlevel || mappack || !stars || !flags ? '' : (h['star'] ? '*' : ' ') + t_rank = cheat ? 'xx' : dev ? '++' : t_rank || h['rank'] || '--' + t_rank = Highscoreable.format_rank(t_rank) + t_player = format_string(h['name'], name_pad) + f_score = !mappack ? 'score' : "score_#{mode}" + t_fmt = decimals > 0 ? "%#{score_pad}.#{decimals}f" : "%#{score_pad}d" + t_score = t_fmt % [h[f_score]] + t_cool = flags && cools && h['cool'] ? " 😎" : "" + t_eql = flags && equal && !h['question'] ? ' =' : '' + t_quest = flags && h['question'] ? ' ?' : '' + + # Put everything together + line = "#{t_star}#{t_rank}: #{t_player} - #{t_score}#{t_cool}#{t_eql}#{t_quest}" + line = "#{color}#{line}#{ANSI.clear}" if color + line + end + + def self.total_scores(type, tabs, secrets, x = true, mappack = nil, board = 'hs', frac = false) + bench(:start) if BENCHMARK + # Prepare fields + klass = !mappack ? Score : MappackScore.where(mappack: mappack) + sfield = !mappack ? '`scores`.`score`' : board == 'hs' ? '`score_hs` / 60.0' : '`score_sr`' + sfield += board == 'hs' ? ' - `fraction` / 60.0' : ' + `fraction`' if frac + tabs = (tabs.empty? ? TABS_SOLO.keys : tabs) - TABS_SECRET if type.include?(Levelish) && !secrets + + # Ignore X row (assuming it's at the end of the tab) + size = TYPES[type.vanilla.to_s][:size] + hfield = !mappack ? '`scores`.`highscoreable_id`' : 'MOD(`mappack_scores`.`highscoreable_id`, 20000)' + sql_ranges = TABS_NEW.select{ |k, v| v[:mode] == MODE_SOLO && !v[:secret] && v[:x] } + .map{ |k, v| + [ + (v[:start] + 5.0 * v[:size] / 6).to_i / size, + (v[:start] + v[:size] - 1).to_i / size + ] + }.map{ |s, e| + "(#{hfield} NOT BETWEEN #{s} AND #{e})" + }.join(' AND ') + + # Compute community total score + total = klass.joins(!mappack && frac ? "INNER JOIN `archives` ON `archives`.`replay_id` = `scores`.`replay_id`" : '') + .where(highscoreable_type: mappack ? type.mappack : type.vanilla) + .where(!tabs.empty? ? { tab: tabs } : {}) + .where(!x ? sql_ranges : '') + .group(:highscoreable_id) + .pluck(board == 'hs' ? "MAX(#{sfield})" : "MIN(#{sfield})") + + bench(:step) if BENCHMARK + [frac ? total.sum : round_score(total.sum), total.count] + end + + def format(name_padding = DEFAULT_PADDING, score_padding = 0, cools = true, mode = 'hs', t_rank = nil) + h = self.as_json + h['name'] = player.print_name if !h.key?('name') + h['score_hs'] /= 60.0 if highscoreable.is_mappack? + Scorish.format(name_padding, score_padding, cools: cools, mode: mode, t_rank: t_rank, mappack: self.is_a?(MappackScore), h: h) + end + + def splits(board = 'hs', event: nil, frac: false) + return if !highscoreable.is_episode? + + # Speedrun splits do not require nsim + if board == 'sr' + scores = Demo.decode(demo.demo, true).map(&:size) + splits = splits_from_scores(scores, start: 0, factor: 1, offset: 0, frac: frac) + return { valid: [true] * 5, scores: scores, splits: splits } + end + return if !FEATURE_NTRACE + + # Execute ntrace in mutex + TmpMsg.update(event, '-# Running simulation...') if event + nsim = NSim.new(highscoreable.levels.map{ |l| l.map.dump_level }, demo.demo) + nsim.run + res = { valid: nsim.valid_flags, splits: nsim.splits, scores: nsim.scores } + nsim.destroy + res + end +end + +class Score < ActiveRecord::Base + include Scorish + belongs_to :player + belongs_to :highscoreable, polymorphic: true +# default_scope -> { select("scores.*, score * 1.000 as score")} # Ensure 3 correct decimal places + create_enum(:tab, TABS_NEW.map{ |k, v| [k, v[:mode] * 7 + v[:tab]] }.to_h) + + # Filter all scores by type, tabs, rank, etc. + def self.filter( + level = 2, # Which filters to apply + player = nil, # Filter scores by player + type = [], # Filter scores by type (Level, Episode, Story) + tabs = [], # Filter scores by tab + a = 0, # Lower rank + b = 20, # Upper rank + ties = false, # Include ties in the above rank range + cool = false, # Include only cool scores + star = false, # Include only star scores + mappack = nil, # Mappack (nil = Metanet) + board = 'hs', # Playing mode + old = false, # Include obsolete scores (only makes sense for mappacks) + dev = false # Include only over-DEV scores + ) + + # Adapt params for mappacks, if necessary + type = fix_type(type) + board = 'hs' if !['hs', 'sr'].include?(board) + ttype = "#{ties ? 'tied_' : ''}rank#{mappack ? "_#{board}" : ''}".to_sym + if !mappack.nil? + klass = MappackScore.where(mappack: mappack) + klass = klass.where.not(ttype => nil) unless old + else + klass = Score + end + + # Build the 3 levels of successive filters + queries = [] + queries.push( + klass.where(!player.nil? ? { player: player } : nil) + .where(highscoreable_type: type) + .where(!tabs.empty? ? { tab: tabs } : nil) + ) + queries.push( + queries.last.where(!a.blank? ? "`#{ttype}` >= #{a}" : nil) + .where(!b.blank? ? "`#{ttype}` < #{b}" : nil) + ) + queries.push( + queries.last.where(cool && !mappack ? { cool: true } : nil) + .where(star && !mappack ? { star: true } : nil) + ) + + # Over-DEV scores are special and require a join + if dev && mappack + join = <<~STR.gsub(/\s+/, ' ').strip + LEFT JOIN `mappack_levels` ON (`mappack_scores`.`highscoreable_type` = 'MappackLevel' AND `mappack_levels`.`id` = `mappack_scores`.`highscoreable_id`) + LEFT JOIN `mappack_episodes` ON (`mappack_scores`.`highscoreable_type` = 'MappackEpisode' AND `mappack_episodes`.`id` = `mappack_scores`.`highscoreable_id`) + LEFT JOIN `mappack_stories` ON (`mappack_scores`.`highscoreable_type` = 'MappackStory' AND `mappack_stories`.`id` = `mappack_scores`.`highscoreable_id`) + STR + dev_score = <<~STR.gsub(/\s+/, ' ').strip + CASE + WHEN `mappack_scores`.`highscoreable_type` = 'MappackLevel' THEN `mappack_levels`.`dev_#{board}` + WHEN `mappack_scores`.`highscoreable_type` = 'MappackEpisode' THEN `mappack_episodes`.`dev_#{board}` + WHEN `mappack_scores`.`highscoreable_type` = 'MappackStory' THEN `mappack_stories`.`dev_#{board}` + ELSE '' + END + STR + queries[-1] = queries[-1].joins(join).where("`score_#{board}` #{board == 'hs' ? '>' : '<'} (#{dev_score})#{board == 'hs' ? ' * 60.0' : ''}") + end + + # Return the appropriate filter + queries[level.clamp(0, queries.size - 1)] + end + + # RANK players based on a variety of different filters and characteristic + def self.rank( + ranking: :rank, # Ranking type. Def: Regular scores. + type: nil, # Highscoreable type. Def: Levels and episodes. + tabs: [], # Highscoreable tabs. Def: All tabs (SI, S, SU, SL, ?, !). + players: [], # Players to ignore. Def: None. + a: 0, # Bottom rank of scores. Def: 0th. + b: 20, # Top rank of scores. Def: 19th. + ties: false, # Include ties or not. Def: No. + cool: false, # Only include cool scores. Def: No. + star: false, # Only include * scores. Def: No. + dev: false, # Only over-DEV scores. Def: No. + frac: false, # Use fractional scores. Def: No. + mappack: nil, # Mappack. Def: None. + board: 'hs' # Highscore or speedrun. Def: Highscore. + ) + board = 'hs' if board.nil? + + # Mappack rankings do not support excluding players yet + players = [] if !mappack.nil? + + # Most rankings which exclude players need to be computed completely + # differently, so we use another function. + if !players.empty? && [:rank, :tied_rank, :points, :avg_points, :avg_rank, :avg_lead].include?(ranking) + return rank_exclude(ranking, type, tabs, ties, b - 1, players) + end + + # Normalize parameters and filter scores accordingly + type = fix_type(type, [:avg_lead, :score, :maxed, :maxable].include?(ranking)) + basetype = type + type = [type].flatten.map{ |t| "Mappack#{t.to_s}".constantize } if !mappack.nil? + level = 2 + level = 1 if (mappack && !dev) || [:maxed, :maxable].include?(ranking) + level = 0 if [:tied_rank, :avg_lead, :singular, :score, :gp, :gm].include?(ranking) + old = [:gp, :gm].include?(ranking) + scores = filter(level, nil, type, tabs, a, b, ties, cool, star, mappack, board, old, dev) + scores = scores.where.not(player: players) if !mappack.nil? && !players.empty? + + # Named fields + rankf = mappack.nil? ? 'rank' : "rank_#{board}" + trankf = "tied_#{rankf}" + sfield = !mappack ? '`scores`.`score`' : board == 'hs' ? '`score_hs` / 60.0' : '`score_sr`' + sfield += board == 'hs' ? ' - `fraction` / 60.0' : ' + `fraction`' if frac + + # Perform specific rankings to filtered scores + bench(:start) if BENCHMARK + case ranking + when :rank + scores = scores.group(:player_id) + .order('`count_id` DESC') + .count(:id) + when :tied_rank + scores_w = scores.where(!a.blank? ? "`#{trankf}` >= #{a}" : nil) + .where(!b.blank? ? "`#{trankf}` < #{b}" : nil) + .group(:player_id) + .order('`count_id` DESC') + .count(:id) + scores_wo = scores.where(!a.blank? ? "`#{rankf}` >= #{a}" : nil) + .where(!b.blank? ? "`#{rankf}` < #{b}" : nil) + .group(:player_id) + .order('`count_id` DESC') + .count(:id) + scores = scores_w.map{ |id, count| [id, count - scores_wo[id].to_i] } + .sort_by{ |id, c| -c } + when :singular + types = type.map{ |t| + ids = scores.where(rankf.to_sym => 1, trankf.to_sym => b, highscoreable_type: t) + .pluck(:highscoreable_id) + scores.where(rankf.to_sym => 0, highscoreable_type: t, highscoreable_id: ids) + .group(:player_id) + .count(:id) + } + scores = types.map(&:keys).flatten.uniq.map{ |id| + [id, types.map{ |t| t[id].to_i }.sum] + }.sort_by{ |id, c| -c } + when :points + scores = scores.group(:player_id) + .order("SUM(#{ties ? "20 - `#{trankf}`" : "20 - `#{rankf}`"}) DESC") + .sum(ties ? "20 - `#{trankf}`" : "20 - `#{rankf}`") + when :avg_points + scores = scores.select("COUNT(`player_id`)") + .group(:player_id) + .having("COUNT(`player_id`) >= #{min_scores(basetype, tabs, false, a, b, star, mappack)}") + .order("AVG(#{ties ? "20 - `#{trankf}`" : "20 - `#{rankf}`"}) DESC") + .average(ties ? "20 - `#{trankf}`" : "20 - `#{rankf}`") + when :avg_rank + scores = scores.select("COUNT(`player_id`)") + .group(:player_id) + .having("COUNT(`player_id`) >= #{min_scores(basetype, tabs, false, a, b, star, mappack)}") + .order("AVG(`#{ties ? trankf : rankf}`)") + .average('`' + (ties ? trankf : rankf) + '`') + when :avg_lead + scores = scores.where(rankf => [0, 1]) + .pluck(:player_id, :highscoreable_id, sfield) + .group_by{ |s| s[1] } + .reject{ |h, s| s.size < 2 } + .map{ |h, s| [s[0][0], (s[0][2] - s[1][2]).abs] } + .group_by{ |s| s[0] } + .map{ |p, s| [p, s.map(&:last).sum.to_f / s.map(&:last).count] } + .sort_by{ |p, s| -s } + when :score + asc = !mappack.nil? && board == 'sr' + scores = scores.joins(!mappack && frac ? "INNER JOIN `archives` ON `archives`.`replay_id` = `scores`.`replay_id`" : '') + .where(!mappack && frac ? { archives: { highscoreable_type: type } } : {} ) + .group(:player_id) + .order(asc ? 'COUNT(`id`) DESC' : '', "`sfield` #{asc ? 'ASC' : 'DESC'}") + .pluck(:player_id, "SUM(#{sfield}) AS `sfield`", "COUNT(`#{rankf}`)") + .map{ |id, score, count| + score = round_score(score.to_f) unless frac + score = score.to_i if mappack && board == 'sr' && !frac + [id, score, asc ? count : nil] + } + when :maxed + scores = scores.where(highscoreable_id: Highscoreable.ties(basetype, tabs, nil, true, true, mappack, board)) + .where("`#{trankf}` = 0") + .group(:player_id) + .order("COUNT(`id`) DESC") + .count(:id) + when :maxable + scores = scores.where(highscoreable_id: Highscoreable.ties(basetype, tabs, nil, false, true, mappack, board)) + .where("`#{trankf}` = 0") + .group(:player_id) + .order("COUNT(`id`) DESC") + .count(:id) + when :gp + query = scores.select(:player_id, :highscoreable_id, 'MAX(`gold`) AS `gold`') + .group(:player_id, :highscoreable_id) + scores = MappackScore.from(query, :t) + .group(:player_id) + .order('`sum_t_gold` DESC') + .sum('`t`.`gold`') + when :gm + query = scores.select(:player_id, :highscoreable_id, 'MIN(`gold`) AS `gold`') + .group(:player_id, :highscoreable_id) + scores = MappackScore.from(query, :t) + .group(:player_id) + .order('COUNT(*) DESC', 'SUM(`gold`) ASC') + .pluck('`t`.`player_id`', 'SUM(`gold`)', 'COUNT(*)') + end + + # Find players and save their display name, if it exists, or their name otherwise + players = Player.where(id: scores.map(&:first)) + .pluck(:id, "IF(`display_name` IS NULL, `name`, `display_name`)") + .to_h + ret = scores.map{ |s| [players[s[0]] || 'Deleted player', s[1], s[2]] } + + # Zeroes are only permitted in a few rankings, and negatives nowhere + ret.reject!{ |s| s[1] <= 0 } unless [:avg_rank, :avg_lead, :gp, :gm].include?(ranking) + + # Sort ONLY ties alphabetically by player + ret.sort!{ |a, b| (a[1] <=> b[1]) != 0 ? 0 : a[0].downcase <=> b[0].downcase } + + bench(:step) if BENCHMARK + ret + end + + # Rankings excluding specified players. Less optimized than the function above + # because I couldn't find a way to ignore them other than loop through all levels + # on a one by one basis. + def self.rank_exclude(ranking, type, tabs, ties = false, n = 0, players = []) + bench(:start) if BENCHMARK + pids = players.map(&:id) + p = Player.pluck(:id).map{ |id| [id, 0] }.to_h + q = Player.pluck(:id).map{ |id| [id, 0] }.to_h + type = [Level, Episode] if type.nil? + t_rank = 0 + t_score = -1 + + [type].flatten.each{ |t| + (tabs.empty? ? t.all : t.where(tab: tabs)).each{ |e| + t_rank = 0 + t_score = 3000.0 + if ranking == :avg_lead + a_id = -1 + a_score = -1 + end + e.scores.reject{ |s| pids.include?(s.player_id) }.sort_by{ |s| s.rank }.each_with_index{ |s, i| + if s.score < t_score + t_rank = i + t_score = s.score + end + case ranking + when :rank + (ties ? t_rank : i) <= n ? p[s.player_id] += 1 : break + when :tied_rank + t_rank <= n ? (i <= n ? next : p[s.player_id] += 1) : break + when :points + p[s.player_id] += 20 - (ties ? t_rank : i) + when :avg_points + p[s.player_id] += 20 - (ties ? t_rank : i) + q[s.player_id] += 1 + when :avg_rank + p[s.player_id] += ties ? t_rank : i + q[s.player_id] += 1 + when :avg_lead + if i == 0 + a_id = s.player_id + a_score = s.score + elsif i == 1 + p[a_id] += a_score - s.score + q[a_id] += 1 + else + break + end + end + } + } + } + + bench(:step) if BENCHMARK + p = p.select{ |id, c| q[id] > (ranking == :avg_lead ? 0 : min_scores(type, tabs)) } + .map{ |id, c| [id, c.to_f / q[id]] } + .to_h if [:avg_points, :avg_rank, :avg_lead].include?(ranking) + p.sort_by{ |id, c| ranking == :avg_rank ? c : -c } + .reject{ |id, c| c == 0 unless [:avg_rank, :avg_lead].include?(ranking) } + .map{ |id, c| [Player.find(id).name, c] } + end + + # Tally levels by count of scores under certain conditions + # If 'list' we return list, otherwise just the count + def self.tally(list, type, tabs, ties = false, cool = false, star = false, a = 0, b = 20) + type = fix_type(type) + res = type.map{ |t| + t_str = t.to_s.downcase.pluralize + query = filter(2, nil, t, tabs, a, b, false, cool, star) + .where(ties ? { tied_rank: 0 } : nil) + .joins("INNER JOIN #{t_str} ON #{t_str}.id = scores.highscoreable_id") + .group(:highscoreable_id) + .order("cnt DESC, highscoreable_id ASC") + .select("#{t_str}.name AS name, count(scores.id) AS cnt") + if list + l = query.map{ |h| [h.name, h.cnt] } + .group_by(&:last) + .map{ |c, hs| [c, hs.map(&:first)] } + .to_h + (0..20).map{ |r| l.key?(r) ? l[r] : [] } + else + Score.from(query).group('cnt').order('cnt').count('cnt') + end + } + if list + (0..20).map{ |r| res.map{ |t| t[r] }.flatten } + else + (0..20).map{ |r| res.map{ |t| t[r].to_i }.sum } + end + end + + def self.holders + bench(:start) if BENCHMARK + sql_str = %{ + SELECT `min`, COUNT(`min`) FROM ( + SELECT MIN(`rank`) AS `min` FROM `scores` GROUP BY `player_id` + ) AS `t` GROUP BY `min`; + }.gsub(/\s+/, ' ').strip + res = sql(sql_str).rows.to_h + ranks = { 0 => res[0] } + (1..19).each{ |r| ranks[r] = ranks[r - 1] + res[r] } + bench(:step) if BENCHMARK + ranks + rescue => e + lex(e, 'Failed to compute unique holders') + nil + end + + def spread + highscoreable.scores.find_by(rank: 0).score - score + end + + def archive + Archive.find_by(replay_id: replay_id, highscoreable: highscoreable) + end + + def demo + archive.demo + end +end + +class Player < ActiveRecord::Base + has_one :streamer + has_many :scores + has_many :player_aliases + has_many :mappack_scores + has_many :mappack_scores_tweaks + alias_method :aliases, :player_aliases + alias_method :tweaks, :mappack_scores_tweaks + + # Only works for 1 type at a time + def self.comparison_(type, tabs, p1, p2) + type = ensure_type(type) + query = Score.where(highscoreable_type: type) + query = query.where(tab: tabs) if !tabs.empty? + t = type.to_s.downcase.pluralize + bench(:start) if BENCHMARK + ids = query.where(player: [p1, p2]) + .joins("INNER JOIN `#{t}` ON `#{t}`.`id` = `scores`.`highscoreable_id`") + .group(:highscoreable_id) + .having('COUNT(`highscoreable_id`) > 1') + .pluck('MIN(`highscoreable_id`)') + scores1 = query.where(highscoreable_id: ids, player: p1) + .joins("INNER JOIN `#{t}` ON `#{t}`.`id` = `scores`.`highscoreable_id`") + .order(:highscoreable_id) + .pluck(:rank, :highscoreable_id, "`#{t}`.`name`", :score) + scores2 = query.where(highscoreable_id: ids, player: p2) + .joins("INNER JOIN `#{t}` ON `#{t}`.`id` = `scores`.`highscoreable_id`") + .order(:highscoreable_id) + .pluck(:rank, :highscoreable_id, "`#{t}`.`name`", :score) + scores = scores1.zip(scores2).group_by{ |s1, s2| s1[3] <=> s2[3] } + s1 = query.where(player: p1) + .where.not(highscoreable_id: ids) + .joins("INNER JOIN `#{t}` ON `#{t}`.`id` = `scores`.`highscoreable_id`") + .pluck(:rank, :highscoreable_id, "`#{t}`.`name`", :score) + .group_by{ |s| s[0] } + .map{ |r, s| [r, s.sort_by{ |s| s[1] }] } + .to_h + s2 = scores.key?(1) ? scores[1].group_by{ |s1, s2| s1[0] } + .map{ |r, s| [r, s.sort_by{ |s1, s2| s1[1] }] } + .to_h + : {} + s3 = scores.key?(0) ? scores[0].group_by{ |s1, s2| s1[0] } + .map{ |r, s| [r, s.sort_by{ |s1, s2| s1[1] }] } + .to_h + : {} + s4 = scores.key?(-1) ? scores[-1].group_by{ |s1, s2| s1[0] } + .map{ |r, s| [r, s.sort_by{ |s1, s2| s2[1] }] } + .to_h + : {} + s5 = query.where(player: p2) + .where.not(highscoreable_id: ids) + .joins("INNER JOIN `#{t}` ON `#{t}`.`id` = `scores`.`highscoreable_id`") + .pluck(:rank, :highscoreable_id, "`#{t}`.`name`", :score) + .group_by{ |s| s[0] } + .map{ |r, s| [r, s.sort_by{ |s| s[1] }] } + .to_h + bench(:step) if BENCHMARK + [s1, s2, s3, s4, s5] + end + + # Merges the results for each type using the previous method + def self.comparison(type, tabs, p1, p2) + type = [Level, Episode] if type.nil? + ret = (0..4).map{ |t| (0..19).to_a.map{ |r| [r, []] }.to_h } + [type].flatten.each{ |t| + scores = comparison_(t, tabs, p1, p2) + (0..4).each{ |i| + (0..19).each{ |r| + ret[i][r] += scores[i][r] if !scores[i][r].nil? + } + } + } + (0..4).each{ |i| + (0..19).each{ |r| + ret[i].delete(r) if ret[i][r].empty? + } + } + ret + end + + # Proxy a login request and register player + def self.login(mappack, req) + # Forward request to Metanet + action_inc('http_login') + res = forward(req) + invalid = res.nil? || res == METANET_INVALID_RES + raise 'Invalid response' if invalid && !LOCAL_LOGIN + locally = false + + if !invalid # Parse server response and register player in database + json = JSON.parse(res) + player = Player.find_or_create_by(metanet_id: json['user_id'].to_i) + player.update( + name: json['name'].to_s, + steam_id: json['steam_id'].to_s, + last_active: Time.now, + active: true + ) + steam_auth = req.query['steam_auth'] + SteamTicket.add_ascii(steam_auth) if !steam_auth.blank? + else # If no response was received, attempt to log in locally + locally = true + + # Parse any param we can find + ids = [req.parts['user_id'].to_i, req.query['user_id'].to_i].uniq + ids.reject!{ |id| id <= 0 || id >= 10000000 } + steamid = req.query['steam_id'].to_s + raise "No parameters found" if ids.empty? && steamid.empty? + + # Try to locate player in the database based on those params + player = nil + ids.each{ |id| + player = Player.find_by(metanet_id: id) + break if !player.nil? + } + player = Player.find_by(steam_id: steamid) if !player + + # Initialize response + json = {} + + # Fill in fields + json['steam_id'] = steamid + if player + json['user_id'] = player.metanet_id + json['name'] = player.name + player.update(steam_id: steamid) if !steamid.empty? + else + id = 0 + name = '' + if !ids.empty? + id = ids[0] + name = "Player #{ids[0]}" + player = Player.create_by(metanet_id: id, name: name) + player.update(steam_id: steamid) if !steamid.empty? + end + json['user_id'] = id + json['name'] = name + end + res = json.to_json + end + + # Return the same response + dbg("#{json['name'].to_s} (#{json['user_id']}) logged in#{locally ? ' locally' : ''} to #{mappack.to_s.upcase}") + res + rescue => e + lex(e, 'Failed to proxy login request') + return nil + end + + # Manually craft and send the login request for this player + def login + parts = [ + { name: 'user_id', binary: false, value: metanet_id.to_s }, + { name: 'size', binary: false, value: '16' }, + { name: 'stats_size', binary: false, value: '0' }, + { name: 'data', binary: true, value: "\x00" * 16 }, + { name: 'stats', binary: true, value: '' } + ] + send_request(:login, parts: parts) + end + + # The player's Steam session authentication ticket sent to N++'s servers + def ticket + return if !steam_id + SteamTicket.find_by(steam_id: steam_id, app_id: APP_ID) + end + + # Get the player's Steam refresh token that can be used to login. It's issued + # on command when we login with username and password and expires in ~200 days. + def refresh_token + return nil if !steam_id + $steam_tokens[steam_id.to_i]&.[](:token) + end + + # Authenticates the player by generating a Steam auth ticket and validating it + # using Steamwork's API. This is then sent to N++'s server in the steam_auth + # parameter of the query. See SteamTicket for more info. + def authenticate + return nil if !refresh_token + if ticket + ticket.refresh(token: refresh_token) + else + SteamTicket.generate(APP_ID, token: refresh_token) + end + end + + # Perform arbitrary HTTP request to N++'s server with this player + # - type: The request to perform out of the following: + # :scores => GET the scores for a level/episode/story + # :replay => GET the demo data for a given replay + # :levels => GET a certain page of userlevels + # :search => GET the result of a userlevel search + # :submit => POST a new score and replay + # :publish => POST a new userlevel + # :login => POST a login request + # - args: URL-encoded arguments of the query. The following are standard and + # are added by default, so only additional ones need to be specified: + # app_id => The Steam ID of N++ (230270) + # user_id => The player ID in Metanet's database + # steam_id => The player ID in Steam's database (SteamID64) + # steam_auth => The full Steam authentication ticket + # - parts: The parts of the multipart form data that will compose the body + # of the request, only necessary for POST requests. Each part is a + # hash with the name, the value, and whether it's binary or not. + # - auth: Whether to attempt to re-authenticate if possible. If we have the + # credentials this will generate a new Steam auth ticket. + # - silent: Print stuff to the terminal + # - discord: Function being called by a Discord user, print useful error messages + # @return: + # nil if the request failed + # false if unauthenticated + # The response body otherwise + def send_request(type, args: {}, parts: [], auth: true, silent: false, discord: false) + dbg("Sending Metanet request #{type} with extra args #{args}...") + + # Ensure we know the player's Steam ID + if !steam_id + send(silent ? :dbg : :err, "Player #{name} (#{metanet_id}) has no Steam ID") + perror("I don't know your Steam ID, please contact the botmaster!") if discord + return + end + + # Enforce authorized requests only. If no valid ticket is found, we may try + # to re-authenticate once. If this fails too, we return. + if (!ticket || ticket.expired?) && (!auth || !authenticate || !ticket || ticket.expired?) + send(silent ? :dbg : :err, "No valid auth ticket found for player #{steam_id}") + return + end + + # Compose URI + args = { + app_id: APP_ID, + user_id: metanet_id, + steam_id: steam_id, + steam_auth: ticket.ascii + }.merge(args) + uri = npp_uri(type, steam_id, **args) + + # Compose body of the multipart form data, if necessary + body = '' + if parts + boundary = rand(36 ** 32).to_s(36) + parts.each{ |p| + body << '--' + boundary + "\r\n" + body << "Content-Disposition: form-data; name=\"#{p[:name]}\"" + body << "; filename=\"#{p[:name]}\"\r\nContent-Type: application/octet-stream" if p[:binary] + body << "\r\n\r\n#{p[:value]}\r\n" + } + body << '--' + boundary + "--\r\n" + end + + # Create HTTP request + method = [:submit, :publish, :login].include?(type) ? :post : :get + case method + when :post + req = Net::HTTP::Post.new(uri) + else + req = Net::HTTP::Get.new(uri) + end + req.body = body + + # Set headers. This is important since the default ones don't work properly, + # so we clean those first. + req.to_hash.keys.each{ |h| req.delete(h) } + req['user-agent'] = 'libcurl-agent/1.0' + req['host'] = METANET_HOST + req['accept'] = '*/*' + if method == :post + req['cache-control'] = 'no-cache' + req['content-length'] = body.size.to_s + req['expect'] = '100-continue' + req['content-type'] = "multipart/form-data; boundary=#{boundary}" + end + + # Perform request + dbg("Metanet query: #{req.method} #{uri} #{req.body.to_s.length}B") + res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 5){ |http| + http.request(req) + } + + # Invalid request + if !res.is_a?(Net::HTTPSuccess) + send(silent ? :dbg : :err, "Failed to request #{type} for player #{steam_id} (code #{res.code.to_i})") + perror("Failed to request #{type} from N++'s server :(") if discord + return + end + + # Unauthenticated + if res.body.to_s == METANET_INVALID_RES + send(silent ? :dbg : :err, "Player #{name} (#{metanet_id}) is not authenticated") + if !auth || !authenticate + perror("You seem to be unauthenticated, please open N++") if discord + return false + end + return send_request(type, args: args, parts: parts, auth: false, silent: silent, discord: discord) + end + + dbg("Received #{res.body.to_s.length} bytes") + res.body.to_s + rescue => e + lex(e, "Error when requesting #{uri}") + nil + end + + # Downloads the scores for the specified highscoreable with this player's Steam ID + # Returns nil on error, and false on unauthenticated + def get_scores(h, qt: 0, discord: false) + return if !h.is_a?(Downloadable) + id_key = (h.is_userlevel? ? 'level' : h.class.to_s.downcase) + '_id' + res = send_request(:scores, args: { qt: qt, id_key => h.id }, discord: discord) + return res unless res + JSON.parse(res) rescue nil + end + + # Fetches the player's score in the specified highscoreable + def get_score(h, discord: false) + # Download scores + json = get_scores(h, discord: discord) + return unless json + scores = h.correct_ties(h.clean_scores(json['scores'])) + + # First, try to find personal score in global leaderboard. + score, rank = scores.each_with_index.find{ |s, i| s['user_id'] == metanet_id } + return { score: score['score'] / 1000.0, rank: rank, replay_id: score['replay_id'] } if score + + # Then, try to find it in the personal field + score = json['userInfo'] + return { score: score['my_score'] / 1000.0, rank: score['my_rank'], replay_id: score['my_replay_id'] } if score + + # No personal score found + err("Player #{name} (#{metanet_id}) has no server score in #{h.name}") + perror("You have no #{h.name} score in the server") if discord + nil + end + + # Download personal score + def get_pb(h) + res = get_scores(h) + return :error if res.nil? + return :inactive if !res + return :none if !res.key?('userInfo') + res['userInfo'] + end + + # Fetches the player's replay given the type and ID + # TODO: I feel like some of this logic should be in the Demo class + def get_replay(h, replay_id, discord: false) + return if !h.is_a?(Downloadable) + + # Perform HTTP request + type = TYPES[h.class.to_s.remove('Mappack')] || TYPES['Level'] + qt = type[:qt] rescue 0 + res = send_request(:replay, args: { qt: qt, replay_id: replay_id }, discord: discord) + return if !res + + # Replay doesn't exist anymore + if res.empty? + err("#{type[:name]} replay #{replay_id} does not exist anymore") + perror("The requested replay doesn't exist in the server anymore") if discord + return + end + + # Integrity checks (see format documentation in Demo) + rt, rid, hid, uid = res[0, 16].unpack('l<4') + if rt != type[:rt] || rid != replay_id || uid != metanet_id + err("Received replay header doesn't match expected values") + perror("Received corrupt replay data from the server") if discord + return + end + + # Parse demo data + Demo.parse(res[16..], type[:name]) + end + + def users(array: true) + list = User.where(player_id: id) + array ? list.to_a : list + end + + def user + users.first + end + + def add_alias(a) + PlayerAlias.find_or_create_by(player: self, alias: a) + end + + def print_name + (display_name || name).remove("`") + end + + def format_name(padding = DEFAULT_PADDING) + format_string(print_name, padding) + end + + def truncate_name(length = MAX_PADDING) + TRUNCATE_NAME ? print_name[0..length] : print_name + end + + def sanitize_name + sanitize_filename(print_name) + end + + # Find the current PB of the player in a mappack highscoreable in a given board + def find_pb(h, board, frac: false) + return if !h.is_mappack? + scores = h.scores.where(player: self) + case board + when 'hs' + score = !frac ? '`score_hs` DESC' : '`score_hs` - `fraction` DESC' + scores.order(score, :date).first + when 'sr' + score = !frac ? '`score_sr` ASC' : '`score_sr` + `fraction` ASC' + scores.order(score, :date).first + when 'gm' + scores.order(:gold, :date).first + when 'gp' + scores.order(gold: :desc, date: :asc).first + end + end + + # Update the rank field of the PB in a given mappack highscoreable, used + # mainly when a player's score changes (is improved, patched or removed) + def update_rank(h, board, frac: false) + return if !h.is_mappack? || !['hs', 'sr'].include?(board) + rankf = 'rank_' + board + trankf = 'tied_' + rankf + scores = h.scores.where(player: self) + scores.update_all(rankf => nil, trankf => nil) + pb = find_pb(h, board, frac: frac) + pb.update(rankf => -1, trankf => -1) if pb + h.update_ranks(board, frac: frac) + end + + def scores_by_type_and_tabs(type, tabs, include = nil, mappack = nil, board = 'hs') + # Fetch complete list of scores + if mappack.nil? + list = scores + else + list = mappack_scores.where(mappack: mappack) + end + + # Filter scores by type and tabs + type = normalize_type(type, mappack: !mappack.nil?) + list = list.where(highscoreable_type: type) + list = list.where(tab: tabs) if !tabs.empty? + + # Further filters for mappack scores + if !mappack.nil? + case board + when 'hs', 'sr' + list = list.where.not("rank_#{board}" => nil) + when 'gm' + list = list.where(gold: 0) + end + end + + # Optionally, include other tables in the result for performance reasons + case include + when :scores + list.includes(highscoreable: [:scores]) + when :name + list.includes(:highscoreable) + else + list + end + end + + def top_ns(n, type, tabs, ties) + scores_by_type_and_tabs(type, tabs).where("#{ties ? "`tied_rank`" : "`rank`"} < #{n}") + end + + # Fetch player's scores (or, alternatively, missing scores) filtering by many + # parameters, like type, tabs, rank... + # + # NOTE: + # If we're asking for missing 'cool' or 'star' scores, we actually take the + # scores the player HAS which are missing the cool/star badge. + # Otherwise, missing includes all the scores the player DOESN'T have. + def range_ns( + a, # Bottom rank (nil = start = 0) + b, # Lower rank (nil = end) + type, # Type + tabs, # Tab list + ties, # Include ties when filtering by rank (use tied rank field) + tied = false, # Only include tied scores when filtering by rank + cool = false, # Scores must be cool + star = false, # Scores must be star (ex-0ths) + missing = false, # Fetch missing scores with the desired properties + mappack = nil, # Mappack to use (nil = Metanet) + board = 'hs', # Leaderboard type + dev = false, # Score must be better than the DEV score + join: false # Join the result set with the highscoreables table + ) + return missing(type, tabs, a, b, ties, tied, mappack, board, dev) if missing && !cool && !star + + # Return highscoreable names rather than scores + high = !mappack.nil? && board == 'gm' + + # Filter scores by type and tabs + ret = scores_by_type_and_tabs(type, tabs, nil, mappack, board) + types = "FIELD(`highscoreable_type`, 'Level', 'Episode', 'Story', 'MappackLevel', 'MappackEpisode', 'MappackStory')" + return ret.order(types, '`highscoreable_id`') if high + + # Optionally join scores with highscoreable tables + if join || dev + c = mappack ? 'Mappack' : '' + t = mappack ? 'mappack_' : '' + join = <<~STR.gsub(/\s+/, ' ').strip + LEFT JOIN `#{t}levels` ON (`#{t}scores`.`highscoreable_type` = '#{c}Level' AND `#{t}levels`.`id` = `#{t}scores`.`highscoreable_id`) + LEFT JOIN `#{t}episodes` ON (`#{t}scores`.`highscoreable_type` = '#{c}Episode' AND `#{t}episodes`.`id` = `#{t}scores`.`highscoreable_id`) + LEFT JOIN `#{t}stories` ON (`#{t}scores`.`highscoreable_type` = '#{c}Story' AND `#{t}stories`.`id` = `#{t}scores`.`highscoreable_id`) + STR + ret = ret.joins(join) + end + + # Filter scores by rank + if mappack.nil? || ['hs', 'sr'].include?(board) + rankf = mappack.nil? ? 'rank' : "rank_#{board}" + trankf = "tied_#{rankf}" + if tied + ret = ret.where(!a.blank? ? "`#{trankf}` >= #{a}" : nil) + .where(!b.blank? ? "`#{trankf}` < #{b}" : nil) + .where(!b.blank? ? "`#{rankf}` >= #{b}" : nil) + else + rank_type = ties ? trankf : rankf + ret = ret.where(!a.blank? ? "`#{rank_type}` >= #{a}" : nil) + .where(!b.blank? ? "`#{rank_type}` < #{b}" : nil) + end + end + + # Filter scores by dev time + if dev && !mappack.nil? && ['hs', 'sr'].include?(board) + dev_score = <<~STR.gsub(/\s+/, ' ').strip + CASE + WHEN `mappack_scores`.`highscoreable_type` = 'MappackLevel' THEN `mappack_levels`.`dev_#{board}` + WHEN `mappack_scores`.`highscoreable_type` = 'MappackEpisode' THEN `mappack_episodes`.`dev_#{board}` + WHEN `mappack_scores`.`highscoreable_type` = 'MappackStory' THEN `mappack_stories`.`dev_#{board}` + ELSE '' + END + STR + ret = ret.where("`score_#{board}` #{board == 'hs' ? '>' : '<'} (#{dev_score})#{board == 'hs' ? ' * 60.0' : ''}") + end + + # Filter scores by cool and star, if not in a mappack + if mappack.nil? + ret = ret.where("#{missing ? 'NOT ' : ''}(`cool` = 1 AND `star` = 1)") if cool && star + ret = ret.where(cool: !missing) if cool && !star + ret = ret.where(star: !missing) if star && !cool + end + + # Order results and return + ret.order("`#{rankf}`", types, '`highscoreable_id`') + end + + def cools(type, tabs, r1 = 0, r2 = 20, ties = false, missing = false) + range_ns(r1, r2, type, tabs, ties).where(cool: !missing) + end + + def stars(type, tabs, r1 = 0, r2 = 20, ties = false, missing = false) + range_ns(r1, r2, type, tabs, ties).where(star: !missing) + end + + def score_counts(tabs, ties, mappack = nil, board = 'hs') + bench(:start) if BENCHMARK + rankf = "`#{ties ? 'tied_' : ''}rank#{mappack ? '_' + board : ''}`" + counts = { + levels: scores_by_type_and_tabs(Level, tabs, nil, mappack, board).group(rankf).order(rankf).count(:id), + episodes: scores_by_type_and_tabs(Episode, tabs, nil, mappack, board).group(rankf).order(rankf).count(:id), + stories: scores_by_type_and_tabs(Story, tabs, nil, mappack, board).group(rankf).order(rankf).count(:id) + } + bench(:step) if BENCHMARK + counts + end + + def missing(type, tabs, a, b, ties, tied = false, mappack = nil, board = 'hs', dev = false) + type = normalize_type(type, mappack: !mappack.nil?) + bench(:start) if BENCHMARK + scores = type.map{ |t| + ids = range_ns(a, b, t, tabs, ties, tied, false, false, false, mappack, board, dev).pluck(:highscoreable_id) + t = t.where(mappack: mappack) if !mappack.nil? + t = t.where(tab: tabs) if !tabs.empty? + t = t.where.not("dev_#{board}" => nil) if mappack && dev + t.where.not(id: ids).order(:id).pluck(:name) + }.flatten + bench(:step) if BENCHMARK + scores + end + + # Return highscoreables with the biggest/smallest differences between the player's + # score and the 0th. + def score_gaps(type, tabs, worst = true, full = false, mappack = nil, board = 'hs', frac = false) + # Prepare params + type = ensure_type(normalize_type(type)) + type = type.mappack if mappack + tname = type.table_name + sfield = !mappack ? '`scores`.`score`' : board == 'hs' ? '`score_hs` / 60.0' : '`score_sr`' + sfield += board == 'hs' ? ' - `fraction` / 60.0' : ' + `fraction`' if frac + rfield = mappack ? "rank_#{board}" : 'rank' + klass = mappack ? MappackScore.where(mappack: mappack).where.not(rfield.to_sym => nil) : Score + tname2 = klass.table_name + klass = klass.where(tab: tabs) unless tabs.empty? + diff = "ABS(MAX(#{sfield}) - MIN(#{sfield}))" + + # Calculate gaps + bench(:start) if BENCHMARK + list = klass.joins(!mappack && frac ? "INNER JOIN `archives` ON `archives`.`replay_id` = `scores`.`replay_id`" : '') + .where(!mappack && frac ? { archives: { highscoreable_type: type } } : {} ) + .joins("INNER JOIN `#{tname}` ON `#{tname}`.`id` = `#{tname2}`.`highscoreable_id`") + .where(highscoreable_type: type) + .where("`#{rfield}` = 0 OR `#{tname2}`.`player_id` = #{self.id}") + .group(:highscoreable_id) + .having('`diff` > 0') + .order("`diff` #{worst ? 'DESC' : 'ASC'}", :highscoreable_id) + .limit(full ? nil : NUM_ENTRIES) + .pluck(:name, "#{diff} AS `diff`") + bench(:step) if BENCHMARK + list + end + + # Return highscoreables with most/least missed pieces of gold. + def gold_gaps(type, tabs, worst = true, full = false, mappack = nil, agd = true) + # Prepare params + type = ensure_type(normalize_type(type)).mappack + tname = type.table_name + klass = MappackScore.where(mappack: mappack, player: self) + klass = klass.where(tab: tabs) unless tabs.empty? + diff = agd ? "`#{tname}`.`gold` - MAX(`mappack_scores`.`gold`)" : 'MIN(`mappack_scores`.`gold`)' + + # Calculate gaps + bench(:start) if BENCHMARK + list = klass.joins("INNER JOIN `#{tname}` ON `#{tname}`.`id` = `highscoreable_id`") + .where(highscoreable_type: type) + .group(:highscoreable_id) + .having('`diff` > 0') + .order("`diff` #{worst ? 'DESC' : 'ASC'}") + .limit(full ? nil : NUM_ENTRIES) + .pluck(:name, "#{diff} AS `diff`") + bench(:step) if BENCHMARK + list + end + + def points(type, tabs) + bench(:start) if BENCHMARK + points = scores_by_type_and_tabs(type, tabs).sum('20 - `rank`') + bench(:step) if BENCHMARK + points + end + + def average_points(type, tabs) + bench(:start) if BENCHMARK + scores = scores_by_type_and_tabs(type, tabs).average('20 - `rank`') + bench(:step) if BENCHMARK + scores + end + + def total_score(type, tabs) + bench(:start) if BENCHMARK + scores = scores_by_type_and_tabs(type, tabs).sum(:score) + bench(:step) if BENCHMARK + scores + end + + def singular_(type, tabs, plural = false) + req = Score.where(highscoreable_type: type.to_s) + req = req.where(tab: tabs) if !tabs.empty? + ids = req.where("`rank` = 1 AND `tied_rank` = #{plural ? 0 : 1}").pluck(:highscoreable_id) + scores_by_type_and_tabs(type, tabs, :name).where(rank: 0, highscoreable_id: ids) + end + + def singular(type, tabs, plural = false) + bench(:start) if BENCHMARK + type = type.nil? ? DEFAULT_TYPES : [type.to_s] + ids = type.map{ |t| singular_(t, tabs, plural).collect(&:id) }.flatten + ret = Score.where(id: ids) + bench(:step) if BENCHMARK + ret + end + + def average_lead(type, tabs) + type = ensure_type(type) # only works for a single type + bench(:start) if BENCHMARK + + ids = top_ns(1, type, tabs, false).pluck('highscoreable_id') + ret = Score.where(highscoreable_type: type.to_s, highscoreable_id: ids, rank: [0, 1]) + ret = ret.where(tab: tabs) if !tabs.empty? + ret = ret.pluck(:highscoreable_id, :score) + count = ret.count / 2 + return 0 if count == 0 + ret = ret.group_by(&:first).map{ |id, sc| (sc[0][1] - sc[1][1]).abs }.sum / count +## alternative method, faster when the player has many 0ths but slower otherwise (usual outcome) +# ret = Score.where(highscoreable_type: type.to_s, rank: [0, 1]) +# ret = ret.where(tab: tabs) if !tabs.empty? +# ret = ret.pluck(:player_id, :highscoreable_id, :score) +# .group_by{ |s| s[1] } +# .map{ |h, s| s[0][2] - s[1][2] if s[0][0] == id } +# .compact +# count = ret.count +# return 0 if count == 0 +# ret = ret.sum / count + + bench(:step) if BENCHMARK + ret + end + + def table(rank, ties, a, b, cool = false, star = false, mappack = nil, board = 'hs', frac = false) + rankf = !mappack ? 'rank' : "rank_#{board}" + trankf = "tied_#{rankf}" + ttype = ties ? trankf : rankf + klass = !mappack ? scores : mappack_scores.where(mappack: mappack).where.not(rankf => nil) + [Level, Episode, Story].map do |type| + if ![:maxed, :maxable].include?(rank) + queryBasic = klass.where(highscoreable_type: mappack ? type.mappack : type) + .where(!cool.blank? && !mappack ? '`cool` = 1' : '') + .where(!star.blank? && !mappack ? '`star` = 1' : '') + query = queryBasic.where(!a.blank? ? "`#{ttype}` >= #{a}" : '') + .where(!b.blank? ? "`#{ttype}` < #{b}" : '') + .group(:tab) + end + case rank + when :rank + query.count(:id).to_h + when :tied_rank + scores1 = queryBasic.where(!a.blank? ? "`#{trankf}` >= #{a}" : '') + .where(!b.blank? ? "`#{trankf}` < #{b}" : '') + .group(:tab) + .count(:id) + .to_h + scores2 = queryBasic.where(!a.blank? ? "`#{rankf}` >= #{a}" : '') + .where(!b.blank? ? "`#{rankf}` < #{b}" : '') + .group(:tab) + .count(:id) + .to_h + scores1.map{ |tab, count| [tab, count - scores2[tab].to_i] }.to_h + when :points + query.sum("20 - `#{ttype}`").to_h + when :score + sfield = !mappack ? '`scores`.`score`' : board == 'hs' ? '`score_hs` / 60.0' : '`score_sr`' + sfield += board == 'hs' ? ' - `fraction` / 60.0' : ' + `fraction`' if frac + query.joins(!mappack && frac ? "INNER JOIN `archives` ON `archives`.`replay_id` = `scores`.`replay_id`" : '') + .where(!mappack && frac ? { archives: { highscoreable_type: type } } : {} ) + .sum(sfield).to_h + when :avg_points + query.average("20 - `#{ttype}`").to_h + when :avg_rank + query.average(ttype).to_h + when :maxed + Highscoreable.ties(type, [], nil, true, false, mappack, board) + .group_by{ |t| t[0].split("-")[mappack ? 1 : 0] } + .map{ |tab, scores| [formalize_tab(tab), scores.size] } + .to_h + when :maxable + Highscoreable.ties(type, [], nil, false, false, mappack, board) + .group_by{ |t| t[0].split("-")[mappack ? 1 : 0] } + .map{ |tab, scores| [formalize_tab(tab), scores.size] } + .to_h + else + query.count(:id).to_h + end + end + end +end + +class LevelAlias < ActiveRecord::Base + belongs_to :level +end + +class PlayerAlias < ActiveRecord::Base + belongs_to :player +end + +class Role < ActiveRecord::Base + def self.exists(discord_id, role) + !self.find_by(discord_id: discord_id, role: role).nil? + end + + def self.add(user, role) + self.find_or_create_by(discord_id: user.id, role: role) + User.find_or_create_by(discord_id: user.id).update(name: user.name) + end + + def self.owners(role) + User.where(discord_id: self.where(role: role).pluck(:discord_id)) + end +end + +class User < ActiveRecord::Base + has_many :palettes + belongs_to :mappack + + # TODO: Change this by a proper Rails association + def player(userlevel: false) + return nil if !player_id + (userlevel ? UserlevelPlayer : Player).find_by(id: player_id) + end + + def player=(player) + self.update(player_id: player ? player.id : nil) + end +end + +class GlobalProperty < ActiveRecord::Base + cattr_reader :status + + # Different types of interactions we log + STATUS_ENTRIES = [ + 'commands', 'main_commands', 'special_commands', 'messages', + 'edits', 'pings', 'dms', 'interactions', + 'logs', 'errors', 'warnings', 'exceptions', + 'http_requests', 'http_errors', 'http_forwards', 'http_scores', + 'http_replay', 'http_submit', 'http_login', 'http_levels', + 'api', 'api_cached', 'api_error', 'api_home', + 'api_scores', 'api_run', 'api_git' + ] + + @@status = STATUS_ENTRIES.map{ |k| [k, 0] }.to_h + + # Get current lotd/eotw/cotm + def self.get_current(type, ctp = false) + klass = ctp ? type.mappack : type + key = "current_#{ctp ? 'ctp_' : ''}#{type.to_s.downcase}" + name = self.find_by(key: key).value rescue nil + return nil if name.nil? + klass.find_by(name: name) + end + + # Set (change) current lotd/eotw/cotm + def self.set_current(type, curr, ctp = false) + key = "current_#{ctp ? 'ctp_' : ''}#{type.to_s.downcase}" + self.find_or_create_by(key: key).update(value: curr.name) + end + + # Select a new lotd/eotw/cotm at random, and mark the current one as done + # When all have been done, clear the marks to be able to start over + def self.get_next(type, ctp = false) + type = type.mappack.where(mappack_id: 1) if ctp + type.where(completed: nil).update_all(completed: false) + type.update_all(completed: false) if type.where(completed: false).count <= 0 + ret = type.where(completed: false).sample + ret.update(completed: true) + ret + end + + # Get datetime for the next update of some property (e.g. new lotd, new + # database score update, etc) + def self.get_next_update(type, ctp = false) + key = "next_#{ctp ? 'ctp_' : ''}#{type.to_s.downcase}_update" + Time.parse(self.find_by(key: key).value) rescue Time.now + end + + # Set datetime for the next update of some property + def self.set_next_update(type, time, ctp = false) + key = "next_#{ctp ? 'ctp_' : ''}#{type.to_s.downcase}_update" + self.find_or_create_by(key: key).update(value: time.to_s) + end + + # Get the old saved scores for lotd/eotw/cotm (to compare against current scores) + def self.get_saved_scores(type, ctp = false) + key = "saved_#{ctp ? 'ctp_' : ''}#{type.to_s.downcase}_scores" + Time.parse(self.find_by(key: key).value) rescue prev_lotd_time(type, ctp: ctp) + end + + # Save the date of the current lotd/eotw/cotm scores (to compute changes later) + def self.set_saved_scores(type, ctp = false) + key = "saved_#{ctp ? 'ctp_' : ''}#{type.to_s.downcase}_scores" + self.find_or_create_by(key: key).update(value: Time.now.to_s) + end + + # Return the last authenticated player we found. + # Note: Any request to N++'s server requires the steam ID of a player that + # is current authenticated with the server. The game will authenticate players + # for an hour, so we need to find one that has played recently enough. + def self.get_current_player + Player.find_by(steam_id: find_or_create_by(key: "last_steam_id").value) + end + + # Save the steam ID of an authenticated player to latch onto for future + # requests to the game's server. Also update activity flags. + def self.set_current_player(player) + find_or_create_by(key: "last_steam_id").update(value: player.steam_id) + player.update(last_active: Time.now) + period = FAST_PERIOD * 24 * 60 * 60 + Player.where("UNIX_TIMESTAMP(`last_active`) >= #{Time.now.to_i - period}").update_all(active: true) + Player.where("UNIX_TIMESTAMP(`last_active`) < #{Time.now.to_i - period}").update_all(active: false) + end + + def self.get_avatar + self.find_by(key: 'avatar').value + end + + def self.set_avatar(str) + self.find_by(key: 'avatar').update(value: str) + end + + def self.statuses + where("`key` LIKE 'status\\_%'") + end + + def self.status_init + entries = statuses.pluck(:key).map{ |k| k.remove('status_') } + create((STATUS_ENTRIES - entries).map{ |k| { key: 'status_' + k, value: '0' } }) + @@status = statuses.pluck(:key, :value).map{ |k, v| [k.remove('status_'), v.to_i] }.to_h + end + + def self.status_persist + @@status.each do |k, v| + entry = find_by(key: 'status_' + k) + entry ? entry.update(value: v.to_s) : create(key: 'status_' + k, value: v.to_s) + end + end + + def self.status_get(key) + @@status[key] + end + + def self.status_set(key, value) + @@status[key] = value + end +end + +# Videos from the N++ Video Library +class Video < ActiveRecord::Base + belongs_to :streamer + belongs_to :highscoreable, polymorphic: true + belongs_to :challenge + + # Fetch sheet and parse new videos + # TODO: Support notes, multiple videos per challenge and streamer, and video deletion + def self.update(filename: nil, silent: false) + # Fetch spreadsheet and parse it + return err("No file named #{filename}") if filename && !File.file?(filename) + data = filename ? File.read(filename) : get_sheet(SHEET_ID_VIDEOS, api: true) + return err("No sheet to parse") unless data + log("Updating video library...") unless silent + json = JSON.parse(data) + indexes = json['sheets'].map.with_index{ |sheet, i| [sheet['properties']['title'], i] }.to_h + + # Basic sheet structure + header_players = 6 + header_videos = 5 + sheet_players = 'Players>List' + sheets_videos = indexes.keys.select{ |k| k =~ /^(solo|hardcore)/i } + dbg("Found #{sheets_videos.size} video sheets to parse") + + # Read video authors + old_streamers = Streamer.pluck(:code) + new_streamers = [] + streamers = json['sheets'][indexes[sheet_players]]['data'][0]['rowData'].drop(header_players).map.with_index do |r, i| + row = r['values'] + next unless row&.size.to_i >= 3 + code = row[1]['formattedValue']&.[](/\[(.+)\]/, 1) + next unless code + next alert("Duplicate player #{code}") if new_streamers.include?(code) + name = row[2]['formattedValue']&.strip + next alert("Player #{code} has no name") unless name + streamer = Streamer.find_or_create_by(code: code) + streamer.update( + name: name, + youtube: row[5]&.[]('hyperlink'), + twitch: row[7]&.[]('hyperlink') + ) + if !streamer.player_id + matches = Player.where_like(:name, name) + streamer.player = matches.first if matches.count == 1 + end + new_streamers << code + [code, streamer] + rescue => e + alert("Failed to parse player #{name} on row #{i + header_players + 1}: #{e}") + end + streamers = streamers.compact.to_h + add_streamers = new_streamers - old_streamers + del_streamers = old_streamers - new_streamers + log( + "Found #{streamers.size} streamers: "\ + "added #{add_streamers.size} (#{add_streamers.join(', ')}), "\ + "removed #{del_streamers.size} (#{del_streamers.join(', ')})" + ) unless silent + + # Read each relevant sheet + old_count = Video.count + counters = Hash.new{ |hash, key| hash[key] = Hash.new(0) } + errors = Hash.new(0) + new_vids = Hash.new(0) + unknown = Set.new + sheets_videos.each do |name| + type = !!name[/level/i] ? Level : !!name[/episode/i] ? Episode : Story + h = nil + + # Read each row + json['sheets'][indexes[name]]['data'][0]['rowData'].drop(header_videos).map.with_index do |r, i| + # Ignore rows without content + row = r['values'] + place = "row #{i + header_videos + 1} from sheet #{name}" + dbg("Parsing #{place}...", progress: true) unless silent + place << " (#{row.map{ |cell| cell['formattedValue'] }.compact.join(', ')})" + symbol = row[4]&.[]('formattedValue') + next unless symbol + errors[:row] += 1 + next alert("Invalid objective found on #{place}") if !symbol[/\[?[!?]*\]?/] + + # Parse highscoreable + h_name = row[1]['formattedValue']&.sub(/^H/, 'S') + h = type.find_by(name: h_name) if h_name.to_s =~ ID_PATTERNS[type.to_s][:vanilla][:dashed] + break err("Failed to find #{type.to_s.downcase} on #{place}") if !h + + # Parse challenge + challenge = nil + next alert("Unsuitable objective found for #{h.name} on #{place}") if type != Level && symbol != '!?' + if h.is_level? && symbol != '[]' + challenge_code = row[1, 3].map{ |cell| cell['formattedValue'] }.compact.join + next alert("No challenge code found for #{h.name} on #{place}") if challenge_code.empty? + q = Challengish.parse_code(challenge_code).merge(level_id: h.id) + challenge = Challenge.find_by(q) + next alert("Challenge not found for #{h.name}: #{challenge_code}") if !challenge + end + + # Parse individual videos + row[5..].each_with_index{ |cell, j| + break if !cell || cell.empty? + errors[:cell] += 1 + author_code = cell['formattedValue']&.[](/\[(.+)\]/, 1) + next alert("Video without valid author code on #{place}") if !author_code + url = cell['hyperlink'] + next alert("Video without link on #{place}") if !url + streamer = streamers[author_code] + if !streamer + unknown << author_code + next alert("Video by unknown author #{author_code} on #{place}") + end + Video.find_or_create_by(highscoreable: h, streamer: streamer, challenge: challenge){ |video| + new_vids[streamer.name] += 1 + }.update(url: url) + counters[type.to_s][symbol] += 1 + errors[:cell] -= 1 + } + errors[:row] -= 1 + rescue => e + alert("Failed to parse #{place}: #{e}") + end # row + + puts + end # sheet + + # Log results unless silent + if !silent + succ("Finished updating video library") + alert( + "Alerts:\n"\ + " #{unknown.size} unknown authors: #{unknown.join(', ')}\n"\ + " #{errors[:row]} skipped challenges\n"\ + " #{errors[:cell]} skipped videos" + ) + cols = counters.keys + rows = counters.map{ |type, hash| hash.keys }.flatten.uniq.sort + table = rows.map{ |symbol| + row = cols.map{ |type| + counters[type][symbol] + } + [symbol, *row, row.sum] + } + table.push(:sep, ['Total', *table.transpose.drop(1).map(&:sum)]) + table.prepend(['', 'Level', 'Episode', 'Story', 'Total'], :sep) + dbg("Video breakdown:\n#{make_table(table)}") + end + + # Send DM notification when new videos get added + new_count = Video.count + if new_count != old_count || !new_vids.empty? + msg = "Found #{new_vids.values.sum} new video library entries by #{new_vids.size} players:\n" + pad_name = new_vids.keys.map(&:length).max + pad_count = new_vids.values.max.to_s.length + list = new_vids.sort_by{ |name, count| -count } + .map{ |name, count| "%-*s - %*d" % [pad_name, name, pad_count, count] } + .join("\n") + files = [] + if new_vids.count <= 20 + msg << format_block(list) + else + files << tmp_file(list, "new_vids.txt") + end + msg << "Before: **%d** videos. After: **%d** videos." % [old_count, new_count] + send_message(botmaster.pm, content: msg, files: files) + end + rescue => e + lex(e, 'Failed to update video library', discord: true) + end +end + +# A streamer is a player who creates content for the game, such as recording +# videos for the N++ video library or broadcasting. +class Streamer < ActiveRecord::Base + has_many :videos + belongs_to :player +end + +# Implemented by Challenge and MappackChallenge +module Challengish + + # Turn challenge code into hash suitable for db query + def self.parse_code(code) + 'GTOCE'.chars.map{ |char| + sign = code[/#{char}((\++)|(-+))/i]&.[](-1) + val = sign ? (sign + '1').to_i : 0 + [char.downcase.to_sym, val] + }.to_h + end + + # Format db hash as standard string + def self.format_code(hash) + hash.map{ |k, v| v == 1 ? "#{k}++" : v == -1 ? "#{k}--" : "" }.join.upcase + end + + # Ensure challenge posting is allowed in this channel + def self.check_channel(channel, discord: true) + return true if channel.type == channel_type(:dm) || CHANNELS_CHALLENGES.include?(channel.id) + mentions = CHANNELS_CHALLENGES.map{ |c| mention_channel(id: c) }.join(', ') + perror("No asking for challenges outside of #{mentions} or DMs!") if discord + false + end + + def objs + { + "G" => self.g, + "T" => self.t, + "O" => self.o, + "C" => self.c, + "E" => self.e + } + end + + def type + index == 0 ? '!' : '?' + end + + def count + objs.select{ |k, v| v != 0 }.count + end + + def format_type(color = false) + (color ? ANSI.yellow : '') + "[" + type * count + "]" + (color ? ANSI.clear : '') + end + + def format_objs(color = false) + good = color ? ANSI.good : '' + bad = color ? ANSI.bad : '' + objs.map{ |k, v| + v == 1 ? good + "#{k}++" : (v == -1 ? bad + "#{k}--" : "") + }.join + (color ? ANSI.clear : '') + end + + def format(color = false, pad = count) + format_type(color) + " " * [1, pad - count + 1].max + format_objs(color) + end +end + +class Challenge < ActiveRecord::Base + include Challengish + belongs_to :level + has_many :videos +end + +class Archive < ActiveRecord::Base + # When archiving began + EPOCH = Time.new(2020, 9, 3, 0, 0, 0, "+00:00") + + belongs_to :player + belongs_to :highscoreable, polymorphic: true + has_one :demo, foreign_key: :id, dependent: :destroy + create_enum(:tab, TABS_NEW.map{ |k, v| [k, v[:mode] * 7 + v[:tab]] }.to_h) + + # Returns the leaderboards at a particular point in time + def self.scores(highscoreable, date = nil, cheated: false, full: false, pluck: true, aliases: true, cools: false, stars: false, **kwargs) + ids = Archive.where(highscoreable: highscoreable) + .where(!cheated ? '`cheated` = 0' : '') + .where(date ? "UNIX_TIMESTAMP(`date`) <= #{date.to_i}" : '') + .group(:metanet_id) + .maximum(:replay_id) + board = Archive.where(highscoreable: highscoreable, replay_id: ids.values) + .joins(:player) + .order(score: :desc, replay_id: :asc) + .limit(full ? nil : 20) + return board unless pluck + + # Optionally pluck only the relevant fields, this is what we tend to use + names = aliases ? 'IF(`display_name` IS NOT NULL, `display_name`, `name`)' : '`name`' + attr_names = %W[id score name metanet_id player_id fraction] + attrs = %W[archives.id score #{names} metanet_id player_id fraction] + board = board.pluck(*attrs).map{ |s| attr_names.zip(s).to_h } + return board if !cools && !stars + + # We don't store cool and star flags, so compute them + cool_count = highscoreable.find_coolness(scores: board, date: date) if cools + board.each_with_index{ |s, i| + s['cool'] = true if cools && i < cool_count + # TODO: Add star here + } + board + end + + # Return a list of all 0th holders in history on a specific highscoreable + # until a certain date (nil = present) + # Care must be taken when the 0th was improved multiple times in the same update + def self.zeroths(highscoreable, date = nil) + dates = highscoreable.changes + return [] if dates.size == 0 + prev_date = dates[0] + zeroth = scores(highscoreable, prev_date).first + zeroths = [zeroth] + date = Time.now.to_i if date.nil? + + dates[0..-2].each_with_index.reject{ |d, i| dates[i + 1] > date }.each{ |d, i| + a = self.where(highscoreable: highscoreable, cheated: false) + .where("UNIX_TIMESTAMP(`date`) > #{d} AND UNIX_TIMESTAMP(`date`) <= #{dates[i + 1]}") + .order('`score` DESC') + .first + if a.score > zeroth[1] + zeroth = [a.metanet_id, a.score] + zeroths.push(zeroth) + end + } + zeroths.map(&:first) + end + + # Clean database: + # - Remove scores, archives and players by blacklisted players + # - Remove orphaned demos (without a corresponding archive) + # - Remove individually blacklisted archives + # - Remove duplicated archives + # TODO: Remove duplicated players and adjust all the tables that might refer to the deleted player_id's + def self.sanitize + # Store results to print summary after sanitization + ret = {} + + # Delete scores by ignored players + query = Score.joins("INNER JOIN `players` ON `players`.`id` = `scores`.`player_id`") + .where("players.metanet_id" => HACKERS.keys + CHEATERS.keys) + count = query.count.to_i + ret['score_del'] = "Deleted #{count} illegitimate scores." unless count == 0 + query.delete_all + + # Delete archives (and their corresponding demos) by hackers + query = Archive.where(metanet_id: HACKERS.keys) + count = query.count.to_i + ret['archive_del'] = "Deleted #{count} archives by hackers." unless count == 0 + query.each(&:destroy) + + # Flag archives by cheaters + query = Archive.where(metanet_id: CHEATERS.keys, cheated: false) + count = query.count.to_i + ret['archive_del'] = "Flagged #{count} archives by cheaters." unless count == 0 + query.update_all(cheated: true) + + # Delete ignored players + query = Player.where(metanet_id: HACKERS.keys).where.not(metanet_id: [OUTTE_ID, OUTTE2_ID]) + count = query.count.to_i + ret['player_del'] = "Deleted #{count} hackers." unless count == 0 + query.delete_all + + # Delete individual incorrect archives + count = 0 + ["Level", "Episode", "Story"].each{ |mode| + query = Archive.where(highscoreable_type: mode, replay_id: PATCH_IND_DEL[mode.downcase.to_sym]) + count += query.count.to_i + query.each(&:destroy) + } + ret['archive_ind_del'] = "Deleted #{count} incorrect archives." unless count == 0 + + # Delete duplicate archives (can happen on accident) + duplicates = Archive.group( + :highscoreable_type, + :highscoreable_id, + :player_id, + :score + ).having('COUNT(`score`) > 1') + .select(:highscoreable_type, :highscoreable_id, :player_id, :score, 'MIN(`date`)') + .to_a + count = 0 + duplicates.each{ |d| + same = Archive.where( + highscoreable_type: d.highscoreable_type, + highscoreable_id: d.highscoreable_id, + player_id: d.player_id, + score: d.score + ).order(date: :asc).limit(1000).offset(1) + count += same.count + same.each(&:destroy) + } + ret['duplicates'] = "Deleted #{count} duplicated archives." unless count == 0 + + # Delete demos with missing archives + query = Demo.joins("LEFT JOIN `archives` ON `archives`.`id` = `demos`.`id`") + .where("`archives`.`id` IS NULL") + count = query.count.to_i + ret['orphan_demos'] = "Deleted #{count} orphaned demos." unless count == 0 + query.delete_all + + # Patch archives + # ONLY EXECUTE THIS ONCE!! Otherwise, the scores will be altered multiple times + #s = Archive.find_by(highscoreable_type: "Level", replay_id: 3758900) + #s.score -= 6 * 60; + #s.save + #s = Archive.find_by(highscoreable_type: "Episode", replay_id: 5067031) + #s.score -= 6 * 60; + #s.save + #PATCH_RUNS.each{ |mode, entries| + # entries.each{ |id, entry| + # Archive.where(highscoreable_type: mode.to_s.capitalize, highscoreable_id: id).where("replay_id <= ?", entry[0]).each{ |a| + # a.score += entry[1] * 60 + # a.save + # } + # } + #} + + ret + end + + # Generate an attract file with this map and replay + def dump_attract(silent: false) + map_data = highscoreable.map.dump_level(query: true) + demo_data = highscoreable.dump_demo(Demo.decode(demo.demo, true)) + [map_data.size, demo_data.size, map_data, demo_data].pack('L<2a*a*') + rescue => e + lex(e, "Failed to dump attract file with ID #{id}") unless silent + return + end + + # Calculate the interpolated fractional frame using Sim's tool + def seed_fraction + # Map or demo not available, cannot compute + if !highscoreable || lost || !demo&.demo + update(fraction: 1) if lost + return :lost + end + + frac = NSim.run(highscoreable.map.dump_level, [demo.demo]){ |nsim| nsim.frac } + update(fraction: frac || 1, simulated: true) + return frac ? :good : :bad + rescue => e + lex(e, "Fraction computation failed for archive #{id}") + end + + # Returns the rank of the player at a particular point in time + def find_rank(time) + Archive.scores(highscoreable, time).index{ |s| s['metanet_id'] == metanet_id } || 20 + end + + # Returns the corresponding score, if it's a current highscore + def highscore + Score.find_by(highscoreable_type: highscoreable_type, replay_id: replay_id) + end +end + +# Implemented by Demo and MappackDemo +module Demoish + def decode(dump = false) + Demo.decode(demo, dump) + end + + def framecount + decode(true).map(&:size).sum rescue -1 + end + + # The complexity of a run is the number of moving entities NSim supports times + # the number of frames. This is used as a threshold to determine when a run is + # too complex to be rendered fully in animations. + def complexity + run = is_a?(Demo) ? archive : score + return -1 if !run || !demo + object_counts = run.highscoreable.levels.map{ |l| l.map.complexity } + frame_counts = decode(true).map(&:size) + object_counts.zip(frame_counts).map{ |a, b| a * b }.sum + end +end + +#------------------------------------------------------------------------------# +# METANET REPLAY FORMAT DOCUMENTATION | +#------------------------------------------------------------------------------# +# REPLAY DATA: | +# 4B - Replay type (0 level / story, 1 episode) | +# 4B - Replay ID | +# 4B - Level ID | +# 4B - User ID | +# Rest - Demo data compressed with zlib | +#------------------------------------------------------------------------------# +# LEVEL DEMO DATA FORMAT: | +# 1B - Type (0 lvl, 1 lvl in ep, 2 lvl in sty) | +# 4B - Data length | +# 4B - Replay version (1) | +# 4B - Frame count | +# 4B - Level ID | +# 4B - Game mode (0, 1, 2, 4) | +# 4B - Unknown (0) | +# 1B - Ninja mask (1, 3) | +# 4B - Static data (0xFFFFFFFF per ninja) | +# Rest - Demo | +#------------------------------------------------------------------------------# +# EPISODE DEMO DATA FORMAT: | +# 4B - Magic number (0xffc0038e) | +# 20B - Block length for each level demo (5 * 4B) | +# Rest - Demo data (5 consecutive blocks, see above) | +#------------------------------------------------------------------------------# +# STORY DEMO DATA FORMAT: | +# 4B - Magic number (0xff3800ce) | +# 4B - Demo data block size | +# 100B - Block length for each level demo (25 * 4B) | +# Rest - Demo data (25 consecutive blocks, see above) | +#------------------------------------------------------------------------------# +# DEMO FORMAT: | +# * One byte per frame. | +# * 1st bit for jump, 2nd for right, 3rd for left, 4th for suicide | +#------------------------------------------------------------------------------# +class Demo < ActiveRecord::Base + include Demoish + belongs_to :archive, foreign_key: :id + + HEADER_SIZE = 26 # cllllllc + Header = Struct.new(:type, :size, :version, :framecount, :id, :mode, :unknown, :mask) + + def self.encode(replay) + replay = [replay] if replay.class == String + Zlib::Deflate.deflate(replay.join('&'), 9) + end + + # Read demo from database (decompress and turn to array) + # Convert to integers, unless we're decoding for dumping later + def self.decode(demo, dump = false) + return nil if demo.nil? + demos = Zlib::Inflate.inflate(demo).split('&') + if !dump + demos = demos.map{ |d| d.bytes } + demos = demos.first if demos.size == 1 + end + demos + end + + # Parse 26 byte header of a level demo + def self.parse_header(replay, inflate: true) + replay = Zlib::Inflate.inflate(replay) if inflate + Header.new(*replay.unpack('Cl<6C')) + end + + # Parse a demo, return array with inputs for each level as binary strings + def self.parse(data, htype) + # Parse header + data = Zlib::Inflate.inflate(data) + length_offset = { 'Level' => 1, 'Episode' => 4, 'Story' => 8 }[htype] + offset = { 'Level' => 0, 'Episode' => 24, 'Story' => 108 }[htype] + header = Demo.parse_header(data[offset, HEADER_SIZE], inflate: false) + ninja_count = header.mask.to_s(2).count('1') + inputs_start = HEADER_SIZE + 4 * ninja_count + + # Extract raw inputs + replays = [] + lengths = data.unpack('x%dl<%d' % [length_offset, TYPES[htype][:size]]) + lengths.each{ |length| + replays << data[offset + inputs_start, length - inputs_start] + offset += length + } + replays + end + + def qt + TYPES[archive.highscoreable_type][:qt] rescue -1 + end + + def parse(replay) + Demo.parse(replay, archive.highscoreable_type) + end + + def get_demo + get_data(:replay, replay_id: archive.replay_id, qt: qt) + end + + def update_archive(framecounts, lost) + return if archive.nil? + framecount = framecounts.sum + archive.update( + framecount: framecount, + gold: framecount != -1 ? (((archive.score + framecount).to_f / 60 - 90) / 2).round : -1, + lost: lost + ) + end + + def update_demo + return if demo + replay = get_demo + return if !replay # replay was not fetched successfully + if replay.empty? # replay does not exist + archive.update(lost: true) + return + end + demos = parse(replay[16..-1]) + update_archive(demos.map(&:size), false) + self.update(demo: Demo.encode(demos)) + rescue => e + lex(e, "Error updating demo with id #{archive.replay_id}: #{e}") + nil + end +end + +# The "raw" classes map to different tables whose purpose is to mirror Metanet's +# db in full as closely as possible. They are kept separate so the highscoring +# functionality is still performed on the much smaller tables. +# Their main purpose is archival and browsing, not really performing stats, +# but their structure has been kept similar to the highscoring ones in case we +# still want some basic stats. + +class RawPlayer < ActiveRecord::Base +end + +class RawScore < ActiveRecord::Base +end + +class RawScore < ActiveRecord::Base +end + +class ScoreCorrection < ActiveRecord::Base + belongs_to :highscoreable, polymorphic: true +end + +# This class logs all messages sent by outte, and who it is in response to +# That way, the user may request to delete the message later by any mechanism +# we decide to devise +class Message < ActiveRecord::Base + # Clear expired message logs + def self.clean + where("date < ?", Time.now - DELETE_TIMELIMIT).delete_all + end +end + +# Done loading file +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) diff --git a/src/threads.rb b/src/threads.rb new file mode 100644 index 00000000..2f90ae6c --- /dev/null +++ b/src/threads.rb @@ -0,0 +1,947 @@ +# This file sets up the background jobs that are running concurrently in separate +# threads, regularly performing periodic tasks such as updating Metanet scores or +# userlevels, posting the level of the day, checking new Twitch streams, etc. +# +# Each of these operations is encapsulated as a Task object, which is a light +# wrapper than handles them gracefully. A task, together with all the necessary +# scheduling functionality, is a Job. Finally, a custom Scheduler module manages +# all the jobs, and includes an event handler to signal other threads for things +# such as task completions. +# +# See also the TASK VARIABLES in src/constants.rb for configuration. See the end +# of the file for the list of tasks. + +$load_time = Time.now + +# Light wrapper that represents an abstract task whose execution is controlled. +# We have graceful exception handling, we know when the task is active, etc. +# @name - Identifier, for logging purposes. If nil, the task won't be logged. +# @db - Whether a MySQL database connection is required for this task. In that +# case, it will be acquired on start and released on stop. +# @log - Whether to log the task start/end to the terminal. +# @block - The Proc object containing the code of the task to execute. +class Task + attr_reader :name, :success, :active + + def initialize(name, db: true, log: true, &block) + # Parameters + @name = name + @db = db + @log = log + @block = block + + # Other members + @active = false + @success = false + end + + def start + log("TASK: Starting \"#{@name}\".") if @log && @name + @active = true + acquire_connection if @db + end + + def stop + release_connection if @db + @active = false + return if !@log || !@name + if @success + succ("TASK: Finished \"#{@name}\" successfully.") + else + err("TASK: Finished \"#{@name}\" with errors.") + end + end + + def execute + @block.call if @block + true + rescue => e + lex(e, "Running task \"#{@name}\".") + false + end + + def run + start + @success = execute + stop + @success + end +end + +# A job represents a task that is scheduled to be performed regularly or periodically. +# @force - If true, this job will prevent restarting the bot when it's active. +# A shutdown can still be forced with Ctrl+C. +# @freq - Frequency of execution in seconds (e.g. daily). 0 means to execute +# it constantly, and <0 means to execute it only once. It measures the +# time passed between finishing the task and starting again, ignoring +# the time execution itself takes. +# @time - Task initial start time. If it's a String, it's the key name in the +# GlobalProperties table of the db containing the start time. +# @start - Start running job immediately after creation, following schedule. +# See Task class below for other parameters. +class Job + cattr_reader :states + attr_reader :task, :count + + @@states = { + :init => { order: 3, desc: 'init' }, + :ready => { order: 2, desc: 'ready' }, + :sleeping => { order: 1, desc: 'sleeping' }, + :running => { order: 0, desc: 'running' }, + } + + def initialize(task, force: true, freq: 0, time: nil, start: true) + # Members + @task = task + @force = force + @freq = nil + @time = nil + @thread = nil + @count = 0 + @date_created = Time.now + @date_started = nil + @date_last = nil + @date_next = nil + @should_stop = true + + # Schedule and start job + time = Time.now if !time + reschedule(freq, time) + start() if start + end + + def scheduled? + !!@freq && !!@time + end + + def running? + !!@thread && @thread.alive? + end + + def active? + @task.active + end + + def free? + !active? || !@force + end + + # Changes scheduling information + def reschedule(freq, time) + @freq = freq + @time = time + end + + # Cancels scheduling, doesn't stop job if already running + def cancel + @freq = nil + @time = nil + end + + # Starts running job according to the specified schedule + def start + # Ensure we can start the job + if !scheduled? + err("Job is not scheduled.") + return false + end + if running? + err("Job is already running.") + return false + end + + # Execute job in a separate thread, inside infinite loop + @should_stop = false + @date_started = Time.now + @thread = Thread.new do + Thread.current.name = @task.name.gsub(/\s+/, '_').downcase + while true + sleep(WAIT) + + # If a start time has been provided, parse it. Otherwise, start now. + if @time.is_a?(String) + start = with_connection do + start = correct_time(GlobalProperty.get_next_update(@time), @freq) + GlobalProperty.set_next_update(@time, start) + start + end + else + @time = Time.now unless @time.is_a?(Time) + start = @time + end + + # Suspend thread until it's time to run the task + @date_next = start + now = Time.now + sleep(start - now) unless start <= now + @task.run + + # Update state based on task success + if @task.success + @count += 1 + @date_last = Time.now + end + Scheduler.trigger(:stopped) + + # Prepare next iteration, if necessary + break if @should_stop + next if !@task.success + break if @freq < 0 + @time = Time.now + @freq if @time.is_a?(Time) + end + rescue => e + lex(e, "Error scheduling job \"#{@task.name}\".") + retry unless @should_stop + ensure + reset + end + + true + end + + # Reset some state variables between thread restarts + def reset + @date_next = nil + @date_started = nil + end + + # Try to stop execution of the job gently (waits till task is completed) + def stop + return if !running? + @should_stop = true + kill if free? + end + + # Forcefully stops execution of the job, even if task is currently running + def kill + return if !running? + reset + @thread.kill + @thread = nil + Scheduler.trigger(:killed) + end + + # Descriptive status of the job + def state + return :running if active? + return :sleeping if running? + return :ready if scheduled? + :init + end + + # When was the last last executed + def time + @date_last + end + + # How long since we started the job + def runtime + return nil if !@date_started + [Time.now - @date_started, 0.0].max + end + + # How long till the task is executed again + def eta + return nil if !@date_next + [@date_next - Time.now, 0.0].max + end + +end + +# Manager class that takes care of scheduling and running jobs +# A job is a task that needs to be executed periodically or regularly +module Scheduler extend self + @@jobs = [] + @@listeners = [] + @@blocking = [] + + def add(name, freq: 0, time: nil, db: true, force: true, log: true, &block) + task = Task.new(name, db: db, log: log, &block) + job = Job.new(task, force: force, freq: freq, time: time) + @@jobs << job + dbg("Scheduled job [#{name}] with frequency #{freq} and start time [#{time ? time : 'now'}]") + end + + # Getters + def list() @@jobs end + def list_scheduled() @@jobs.select{ |job| job.scheduled? } end + def list_running() @@jobs.select{ |job| job.running? } end + def list_active() @@jobs.select{ |job| job.active? } end + def list_blocking() @@jobs.select{ |job| !job.free? } end + + # Counters + def count() @@jobs.count end + def count_scheduled() @@jobs.count{ |job| job.scheduled? } end + def count_running() @@jobs.count{ |job| job.running? } end + def count_active() @@jobs.count{ |job| job.active? } end + def count_blocking() @@jobs.count{ |job| !job.free? } end + + # Clean dead blocking threads + def clean + @@blocking.reject!{ |thr| !thr.alive? } + end + + # Whether no forced background tasks are active + def free? + clean + @@jobs.all?(&:free?) && @@blocking.empty? + end + + # Gracefully stop all jobs + def free + @@jobs.each(&:stop) + clean + broadcast(:free) if free? + end + + # Forcefully kill all jobs + def terminate + @@jobs.each(&:kill) + @@blocking.each(&:kill) + end + + # Register thread, which will prevent shutting down the bot + def lock + @@blocking << Thread.current + end + + # Unregister thread to stop it from blocking shutdowns + def unlock + @@blocking.delete(Thread.current) + end + + # Run block of code while locking shutdowns + def with_lock(&block) + lock + yield + ensure + unlock + end + + # A thread can register to be woken up by a specific event + def listen(event) + @@listeners << { event: event, thread: Thread.current } + sleep + end + + # Broadcast an event to all threads listening to it + def broadcast(event) + @@listeners.each{ |ls| + ls[:thread].run if ls[:event] == event + } + end + + # Handle what happens when an event is raised + def trigger(event) + # Always broadcast the main event + broadcast(event) + + # Test if other events need to be broadcasted + case event + when :stopped + broadcast(:free) if free? + end + end +end + +# <----------------------------------------------------------------------------> +# <------ TASK DEFINITIONS ------> +# <----------------------------------------------------------------------------> + +# Periodically (every ~5 mins) perform several useful tasks. +def update_status + if !OFFLINE_STRICT + # Download newest userlevels from all 3 modes + [MODE_SOLO, MODE_COOP, MODE_RACE].each do |mode| + Userlevel.parse(Userlevel.get_levels(QT_NEWEST, 0, mode)) + rescue + next + end + + # Update scores for lotd, eotw and cotm + GlobalProperty.get_current(Level).update_scores + GlobalProperty.get_current(Episode).update_scores + GlobalProperty.get_current(Story).update_scores + end + + # Update bot's status and activity (it only lasts so much) + update_bot_status + + # Clear old message logs and userlevel query cache + Message.clean + UserlevelCache.clean + + # Persist bot status (action counters, etc) to db + GlobalProperty.status_persist + + # Verify Steam refresh tokens are still valid and not expired + verify_steam_tokens + + # Finish + $status_update = Time.now.to_i +end + +# Check for new Twitch streams, and send notices. +def update_twitch + Twitch::update_twitch_streams + Twitch::new_streams.each{ |game, list| + list.each{ |stream| + Twitch::post_stream(stream) + } + } +end + +# Check for newly submitted / verified / rejected speedruns submitted to Speedrun.com +# and send notices +def update_speedrun + new_runs = Speedrun::fetch_new_runs + new_runs.each_slice(DISCORD_EMBED_LIMIT){ |runs| + embeds = runs.map{ |run| Speedrun::format_embed(run) } + send_message($speedrun_channel, embeds: embeds) + sleep(0.5) + } + succ("Reported #{new_runs.size} new speedruns") if new_runs.size > 0 +end + +# Monitor several N++-related RSS feeds (Steam, SteamDB) for news and send notices +def update_rss + Feed::check +end + +# Monitor N++ patches on Steam +def update_steam + token = $steam_tokens[DATA_STEAM_ID]&.[](:token) + return unless token + SteamApp.find_or_create_by(id: APP_ID).fetch_info(token: token) +end + +# Update missing demos (e.g., if they failed to download originally) +def download_demos + Archive.where(lost: false) + .joins("LEFT JOIN demos ON demos.id = archives.id") + .where("demos.demo IS NULL") + .find_each{ |ar| Demo.find_or_create_by(id: ar.id).update_demo } +end + +# Compute and send the weekly highscoring report +def generate_report(date = nil) + base = Archive::EPOCH # when archiving begun + now = Time.now + time = [date || now - REPORT_UPDATE_SIZE, base].max + pad = [2, DEFAULT_PADDING, 6, 6, 6, 5, 4] + log = [] if LOG_REPORT + sep = '│' + + changes = Archive.where("UNIX_TIMESTAMP(`date`) > #{time.to_i} AND `cheated` = 0") + .order('`date` DESC') + .map{ |ar| [ar.metanet_id, ar.find_rank(time), ar.find_rank(now), ar.highscoreable, ar.score] } + .group_by{ |s| s[0] } + .map{ |id, scores| + [ + id, + scores.group_by{ |s| s[3] } + .map{ |highscoreable, versions| + max = versions.map{ |v| v[4] }.max + versions.select{ |v| v[4] == max }.first + } + ] + } + .map{ |id, scores| + log << [Player.find_by(metanet_id: id).name, scores.sort_by{ |s| [s[2], s[3].id] }] if LOG_REPORT + { + player: Player.find_by(metanet_id: id).name, + points: scores.map{ |s| s[1] - s[2] }.sum, + top20s: scores.select{ |s| s[1] == 20 }.size, + top10s: scores.select{ |s| s[1] > 9 && s[2] <= 9 }.size, + top05s: scores.select{ |s| s[1] > 4 && s[2] <= 4 }.size, + zeroes: scores.select{ |s| s[2] == 0 }.size + } + } + .sort_by{ |p| -p[:points] } + .each_with_index + .map{ |p, i| + values = p.values.prepend(i) + values.each_with_index.map{ |v, j| + s = v.to_s.rjust(pad[j], " ")[0..pad[j]-1] + s += ' ' + sep if [0, 1, 2].include?(j) + s + }.join(" ") + } + .take(20) + .join("\n") + + header = ["", "Player", "Points", "Top20s", "Top10s", "Top5s", "0ths"] + .each_with_index + .map{ |h, i| + s = h.ljust(pad[i], " ") + s += ' ' if [0, 1, 2].include?(i) + s + } + .join(" ") + header = ANSI.under + ANSI.bold + header + ANSI.clear + sep = "-" * (pad.sum + pad.size + 5) + + if LOG_REPORT + log_text = log.sort_by{ |name, scores| name }.map{ |name, scores| + scores.map{ |s| + name[0..14].ljust(15, " ") + " " + (s[1] == 20 ? " x " : s[1].ordinalize).rjust(4, "0") + "->" + s[2].ordinalize.rjust(4, "0") + " " + s[3].name.ljust(10, " ") + " " + ("%.3f" % (s[4].to_f / 60.0)) + }.join("\n") + }.join("\n") + File.write(PATH_LOG_REPORT, log_text) + end + + "## 📋 Weekly highscoring report:\n#{format_block([header, changes].join("\n"))}" +rescue => e + lex(e, 'Error generating highscoring report') + 'Failed to generate daily highscoring report :(' +end + +# Compute and send the daily highscoring summary: +# 1) Seconds of total score gained. +# 2) Seconds of total score in 19th gained. +# 3) Total number of changes. +# 4) Total number of involved players. +# 5) Total number of involved highscoreables. +def generate_summary(date = nil) + base = Archive::EPOCH # when archiving begun + now = Time.now + time = [date || now - SUMMARY_UPDATE_SIZE, base].max + total = { "Level" => [0, 0, 0, 0, 0], "Episode" => [0, 0, 0, 0, 0], "Story" => [0, 0, 0, 0, 0] } + + changes = Archive.where("UNIX_TIMESTAMP(`date`) > #{time.to_i} AND `cheated` = 0") + .order('`date` DESC') + .map{ |ar| + total[ar.highscoreable.class.to_s][2] += 1 + [ar.metanet_id, ar.highscoreable] + } + changes.group_by{ |s| s[1].class.to_s } + .each{ |klass, scores| + total[klass][3] = scores.uniq{ |s| s[0] }.size + total[klass][4] = scores.uniq{ |s| s[1].id }.size + } + changes.map{ |h| h[1] } + .uniq + .each{ |h| + cur_scores = Archive.scores(h, now) + old_scores = Archive.scores(h, time) + total[h.class.to_s][0] += cur_scores.first['score'] - (old_scores.first || { 'score' => 0 })['score'] + total[h.class.to_s][1] += cur_scores.last['score'] - (old_scores.last || { 'score' => 0 })['score'] + } + + total = total.map{ |klass, n| + "• There were **#{n[2]}** new scores by **#{n[3]}** players " + + "in **#{n[4]}** #{klass.downcase.pluralize}, " + + "making the boards **#{"%.3f" % [n[1].to_f / 60.0]}** seconds harder " + + "and increasing the total 0th score by **#{"%.3f" % [n[0].to_f / 60.0]}** seconds." + }.join("\n") + "## 📈 Daily highscoring summary:\n" + total +rescue => e + lex(e, 'Error generating highscoring summary') + 'Failed to generate daily highscoring summary :(' +end + +# Compute and send the daily userlevel highscoring report for the newest +# 500 userlevels. +def send_userlevel_report(channel = nil, histories: true, date: nil) + channel = $mapping_channel if !channel + while !channel + err("Not connected to a channel, not sending userlevel report") + sleep(5) + channel = $mapping_channel + end + + report1 = UserlevelHistory.report(1, footer: false, date: date) + report2 = UserlevelHistory.report(-1, date: date) + if report1.length + report2.length + 1 <= DISCORD_CHAR_LIMIT + send_message(channel, content: report1 + "\n" + report2) + else + send_message(channel, content: report1) + sleep(0.25) + send_message(channel, content: report2) + end + update_userlevel_histories if histories +end + +# Update database scores for Metanet Solo levels, episodes and stories +def download_high_scores + [Level, Episode, Story].each do |type| + type.all.each do |o| + attempts ||= 0 + o.update_scores + rescue => e + lex(e, "Downloading high scores for #{o.class.to_s.downcase} #{o.id.to_s}") + ((attempts += 1) <= ATTEMPT_LIMIT) ? retry : next + end + end +end + +# Precompute and store several useful userlevel rankings daily, so that we +# can check the history later, since we don't have a differential table here. +def update_userlevel_histories + now = Time.now + [-1, 1, 5, 10, 20].each{ |rank| + rankings = Userlevel.rank(rank == -1 ? :points : :rank, rank == 1, rank - 1, true) + attrs = UserlevelHistory.compose(rankings, rank, now) + ActiveRecord::Base.transaction do + UserlevelHistory.create(attrs) + end + } +end + +# Download the scores for the scores for the latest 500 userlevels, for use in +# the daily userlevel highscoring rankings. +def download_userlevel_scores + Userlevel.where(mode: :solo).order(id: :desc).take(USERLEVEL_REPORT_SIZE).each do |u| + attempts ||= 0 + u.update_scores + rescue => e + lex(e, "Downloading scores for userlevel #{u.id}") + ((attempts += 1) <= ATTEMPT_LIMIT) ? retry : next + end +end + +# Continuously, but more slowly, download the scores for ALL userlevels, to keep +# the database scores reasonably up to date. +# We select the userlevels to update in reverse order of last update, i.e., we +# always update the ones which haven't been updated the longest. +def update_all_userlevels_chunk + dbg("Downloading next userlevel chunk scores...") + Userlevel.where(mode: :solo).order('score_update IS NOT NULL, score_update').take(USERLEVEL_DOWNLOAD_CHUNK).each do |u| + sleep(USERLEVEL_UPDATE_RATE) + attempts ||= 0 + u.update_scores + rescue => e + lex(e, "Downloading scores for userlevel #{u.id}") + ((attempts += 1) <= ATTEMPT_LIMIT) ? retry : next + end + dbg("Downloaded userlevel chunk scores") +end + +# Download some userlevel tabs (best, top weekly, featured, hardest), for all +# 3 modes, to keep those lists up to date in the database +def update_userlevel_tabs + [MODE_SOLO, MODE_COOP, MODE_RACE].each{ |mode| + USERLEVEL_TABS.select{ |k, v| v[:update] }.keys.each{ |qt| + page = 0 + page += 1 while Userlevel.parse(Userlevel.get_levels(qt, page, mode)) + UserlevelTab.where(mode: mode, qt: qt) + .where("`index` >= #{USERLEVEL_TABS[qt][:size]}") + .delete_all unless USERLEVEL_TABS[qt][:size] == -1 + } + } +end + +# Re-download a full page of userlevels for each mode. This way we keep certain +# stats (notably ++'s, but potentially also author name changes) up-to-date. +# Note we skip page 0 (that's taken care of). +def update_userlevel_data + [MODE_SOLO, MODE_COOP, MODE_RACE].each{ |mode| + prop = GlobalProperty.find_or_create_by(key: "current_page_#{MODES[mode]}") + page = prop.value.to_i + 1 + more = Userlevel.parse(Userlevel.get_levels(QT_NEWEST, page, mode)) + prop.update(value: more ? page : more.nil? ? page - 1 : 0) + dbg("Updated userlevel page #{page} of mode #{mode}") + } +end + +# Continuously monitor db for newly submitted scores in both vanilla levels +# and mappack levels in order to simulate them and precompute their fraction +def compute_new_fractions + seed_fractional_scores(nil, false) +end + +############ LOTD FUNCTIONS ############ + +# Reminder for the current lotd/eotw/cotm +def lotd_reminder(type, ctp: false) + h = GlobalProperty.get_current(type, ctp) + time = format_timestamp(next_lotd_time(type, ctp: ctp), rel: true) + period = type == Level ? 'day' : type == Episode ? 'week' : 'month' + type = type == Story ? 'column' : type.to_s.downcase + name = "current #{ctp ? 'CTP ' : ''}#{type} of the #{period}" + if h + "The #{name} is #{h.format_name(bold: true)} and ends #{time}." + else + "There is no #{name}, but I'll post a new one #{time}" + end +rescue => e + lex(e, "Failed to draft #{type.to_s.downcase} reminder") +end + +# Reminder list after lotd +def lotd_reminders(ctp: false, episode: true, story: true) + message = '' + message << "## 📌 Reminder:\n" if episode || story + message << '╰┈➢ ' << lotd_reminder(Episode, ctp: ctp) << "\n" if episode + message << '╰┈➢ ' << lotd_reminder(Story, ctp: ctp) << "\n" if story + message +rescue => e + lex(e, 'Failed to draft lotd reminders') +end + +# Publish the lotd/eotw/cotm +# This function also updates the scores of said board, and of the new one +def send_channel_next(channel, type, ctp: false, save: true) + # Get old and new levels/episodes/stories + last = GlobalProperty.get_current(type, ctp) + current = GlobalProperty.get_next(type, ctp) + GlobalProperty.set_current(type, current, ctp) if save + if current.nil? + err("No more #{ctp ? 'CTP ' : ''}#{type.to_s.downcase.pluralize}") + return false + end + + # Update scores, if need be + if !OFFLINE_STRICT && UPDATE_SCORES_ON_LOTD && !ctp && save + last.update_scores if !last.nil? + current.update_scores + end + + # Format caption + prefix = type == Level ? 'Time' : "It's also time" + type_n = type == Level ? 'level' : type == Episode ? 'episode' : 'column' + period = type == Level ? 'day' : type == Episode ? 'week' : 'month' + time = type == Level ? 'today' : "this #{period}" + caption = "# 🔔 __New #{ctp ? 'CTP ' : ''}#{type_n} of the #{period}!__\n" + caption << "## ➤ #{current.format_name}" + + # Presentation and screenshot + screenshot = Map.screenshot(file: true, h: current.map) rescue nil + caption += "\nThere was a problem generating the screenshot!" if screenshot.nil? + send_message(channel, content: caption, files: screenshot.nil? ? [] : [screenshot]) + sleep(0.25) + + # Scores + caption1 = "Current #{OFFLINE_STRICT ? "(cached) " : ""}high scores:\n" + caption1 << format_block(current.format_scores(mode: 'dual')) + + # Differences, if available + date = GlobalProperty.get_saved_scores(type, ctp) + if last.nil? || date.nil? + caption2 = "There was no previous #{ctp ? 'CTP ' : ''}#{type_n} of the #{period}." + else + diff = last.format_difference(date, 'dual') + caption2 = last.format_difference_message(diff, date, past: true) + end + + # Send message(s) + if caption1.length + caption2.length + 1 <= DISCORD_CHAR_LIMIT + send_message(channel, content: caption1 + caption2) + else + send_message(channel, content: caption1) + sleep(0.25) + send_message(channel, content: caption2) + end + + # Save date for next lotd/eotw/cotm score diff + GlobalProperty.set_saved_scores(type, ctp) if save + return true +end + +# General driver for the function above +def start_level_of_the_day(channel = nil, ctp: false, save: true, test: false) + # Flags + lotd_day = true + eotw_day = Time.now.sunday? + cotm_day = Time.now.day == 1 + test_lotd = (ctp ? TEST_CTP_LOTD : TEST_LOTD) || test + post_lotd = test_lotd || ((ctp ? POST_CTP_LOTD : POST_LOTD) || DO_EVERYTHING) && lotd_day + post_eotw = test_lotd || ((ctp ? POST_CTP_EOTW : POST_EOTW) || DO_EVERYTHING) && eotw_day + post_cotm = test_lotd || ((ctp ? POST_CTP_COTM : POST_COTM) || DO_EVERYTHING) && cotm_day + + # Ensure channel is available + return if !post_lotd && !post_eotw && !post_cotm + channel = ctp ? $ctp_channel : $channel if !channel + while !channel + err("#{ctp ? 'CTP h' : 'H'}ighscoring channel not found, not sending level of the day") + sleep(5) + channel = ctp ? $ctp_channel : $channel + end + + # Post each highscoreable, if enabled + send_channel_next(channel, Level, ctp: ctp, save: save) if post_lotd + sleep(0.25) + send_channel_next(channel, Episode, ctp: ctp, save: save) if post_eotw + sleep(0.25) + send_channel_next(channel, Story, ctp: ctp, save: save) if post_cotm + + # Draft reminders + post_eotw_rem = test_lotd || post_lotd && !eotw_day + post_cotm_rem = test_lotd || post_lotd && !cotm_day + message = lotd_reminders(ctp: ctp, episode: post_eotw_rem, story: post_cotm_rem) + + # Draft report and summary + post_report = !ctp && (REPORT_METANET || TEST_LOTD || DO_EVERYTHING || test) + message << generate_report << "\n" << generate_summary if post_report + + # Send final message + if !message.empty? + sleep(0.25) + send_message(channel, content: message) + end +end + +# Prevent running out of memory due to memory leaks and risking the OOM killer +# from obliterating outte by preemptively restarting it when no active tasks +# (e.g. lotd or score update) are being executed. +def monitor_memory + # Gather memory info + mem = getmem + total = meminfo['MemTotal'] + available = meminfo['MemAvailable'] + used = total - available + + # If below 25% of available memory, take action + available_ratio = available.to_f / total + used_ratio = mem.to_f / used + if available_ratio < MEMORY_LIMIT.clamp(0, 1) + str = "#{"%.2f%%" % [100 - 100 * available_ratio]} used, #{"%.2f%%" % [100 * used_ratio]} by outte" + if used_ratio > MEMORY_USAGE.clamp(0, 1) + _thread do restart("Lack of memory (#{str})") end + elsif !$memory_warned + alert("Something's taking up excessive memory, and it's not outte! (#{str})", discord: true) + $memory_warned = true + end + end + + # If below 5%, send another warning to Discord, regardless of outte usage + if available_ratio < MEMORY_CRITICAL.clamp(0, 1) && !$memory_warned_c + alert("Memory usage is critical! (#{"%.2f%%" % [100 - 100 * available_ratio]})", discord: true) + $memory_warned_c = true + end +end + +# Prevent running out of available database connections +def monitor_db + update_sql_status + + # MySQL threads + cur = $sql_status['Threads_connected'].to_i + max = $sql_vars['max_connections'].to_i + ratio = cur.to_f / max + restart("Lack of MySQL threads (#{cur} / #{max})") if ratio >= SQL_LIMIT + + # Rails pool + stats = ActiveRecord::Base.connection_pool.stat + cur = stats[:connections] + max = stats[:size] + ratio = cur.to_f / max + alert("Lack of Rails pool connections (#{cur} / #{max})", discord: true) if ratio >= POOL_LIMIT +end + +def potato + return false if !$nv2_channel || !$last_potato + return false if Time.now.to_i - $last_potato.to_i < POTATO_FREQ + send_message($nv2_channel, content: FOOD[$potato], db: false) + log(FOOD[$potato].gsub(/:/, '').capitalize + 'ed nv2') + $potato = ($potato + 1) % FOOD.size + $last_potato = Time.now.to_i +end + +# <----------------------------------------------------------------------------> +# <------ SCHEDULE TASKS ------> +# <----------------------------------------------------------------------------> + +# These tasks are completely autonomous and don't depend on any other service +# being up, such as Metanet's server or Discord's connection. +# They're the first ones to be started. +def start_general_tasks + # Monitor machine's RAM regularly, restart outte when needed (Linux only). + Scheduler.add("Monitor memory", freq: MEMORY_DELAY, db: false, force: false, log: false) { monitor_memory } if MEMORY_MONITOR && $linux + + # Monitor available MySQL threads regularly + Scheduler.add("Monitor database", freq: SQL_DELAY, force: false, log: false) { monitor_db } if SQL_MONITOR + + # Monitor new scores and simulate them + Scheduler.add("Compute fractions", freq: FRACTION_FREQUENCY, force: false, log: false) { compute_new_fractions } if DO_EVERYTHING || COMPUTE_FRACTIONS + + # Fetch and parse the video library + Scheduler.add("Video library", freq: VIDEO_UPDATE_FREQUENCY, time: 'video') { Video.update(silent: true) } if DO_EVERYTHING || UPDATE_VIDEOS + + # Update the mappack digest containing generic information about supported mappacks + Scheduler.add("Mappack digest", freq: MAPPACK_DIGEST_FREQUENCY){ Mappack.digest } if MAPPACK_DIGEST + + # Custom Leaderboard Engine (provides native leaderboard support for mappacks). + if SOCKETS && CLE_ON && !DO_NOTHING + $servers['cle'] = NPPServer.new + $servers['cle'].start + end + + # Start API to provide external outte functionality + if SOCKETS && API_ON && !DO_NOTHING + $servers['api'] = APIServer.new(log_res: false) + $servers['api'].start + end +end + +# These tasks perform periodic operations querying Metanet's database (e.g. to +# fetch scores or new userlevels), and thus, they rely on their server being up. +# We always start them up, but they will fail if it's not up. +def start_metanet_tasks + return if DO_NOTHING || OFFLINE_MODE + + # Update all Metanet top20 highscores daily + freq = TEST && TEST_SCORES ? -1 : HIGHSCORE_UPDATE_FREQUENCY + time = TEST && TEST_SCORES ? nil : 'score' + Scheduler.add("Download scores", freq: freq, time: time) { download_high_scores } if DO_EVERYTHING || UPDATE_SCORES || TEST && TEST_SCORES + + # Download demos for scores missing it daily + Scheduler.add("Download demos", freq: DEMO_UPDATE_FREQUENCY, time: 'demo') { download_demos } if DO_EVERYTHING || UPDATE_DEMOS + + # Download scores for newest userlevels daily + Scheduler.add("Userlevel scores", freq: USERLEVEL_SCORE_FREQUENCY, time: 'userlevel_score') { download_userlevel_scores } if DO_EVERYTHING || UPDATE_USERLEVELS + + # Update userlevels present in each tab (hardest, featured...) daily + Scheduler.add("Userlevel tabs", freq: USERLEVEL_TAB_FREQUENCY, time: 'userlevel_tab') { update_userlevel_tabs } if DO_EVERYTHING || UPDATE_USER_TABS + + # Gradually update all userlevel scores (every 5 secs) + Scheduler.add("Userlevel chunk", force: false, log: false) { update_all_userlevels_chunk } if DO_EVERYTHING || UPDATE_USER_GLOB + + # Gradually update all userlevel data (every hour) + Scheduler.add("Userlevel data", freq: USERLEVEL_DATA_RATE, force: false, log: false) { update_userlevel_data } if DO_EVERYTHING || UPDATE_USER_INFO +end + +# These tasks perform operations relying on a Discord connection to the N++ +# server, such as posting lotd or the highscoring report. We start these last, +# and only on the condition that the connection has been established. +def start_discord_tasks + return if DO_NOTHING + + # Update the bot's status, update lotd scores, etc, every 5 mins + Scheduler.add("Update status", freq: STATUS_UPDATE_FREQUENCY, log: false) { update_status } if DO_EVERYTHING || UPDATE_STATUS + + # Check for new N++-related streams of Twitch every minute + Scheduler.add("Update Twitch", freq: TWITCH_UPDATE_FREQUENCY, log: false, db: false, force: false) { update_twitch } if DO_EVERYTHING || UPDATE_TWITCH + + # Check for new N++-related speedruns every minute + Scheduler.add("Update Speedrun", freq: SPEEDRUN_UPDATE_FREQUENCY, log: false) { update_speedrun } if DO_EVERYTHING || UPDATE_SPEEDRUN + + # Check for new N++-related RSS news every minute + Feed.new('steam', RSS_STEAM_URI % APP_ID, TEST ? nil : CHANNEL_MAIN, title: "💥 New N++ update on Steam!") + Feed.new('assembla', ASSEMBLA_URI, TEST ? nil : CHANNEL_SUPPORT, title: "📋 New Assembla ticket submitted", type: 'assembla') + Scheduler.add("Update RSS", freq: RSS_UPDATE_FREQUENCY, log: false) { update_rss } if DO_EVERYTHING || UPDATE_RSS + + # Check for new N++ Steam patches every 5 minutes + Scheduler.add("Update Steam patches", freq: STEAM_UPDATE_FREQUENCY, log: false) { update_steam } if DO_EVERYTHING || UPDATE_STEAM + + # Post lotd daily, eotw weekly and cotm monthly + freq = TEST && TEST_LOTD ? -1 : LEVEL_UPDATE_FREQUENCY + time = TEST && TEST_LOTD ? nil : 'level' + Scheduler.add("Level of the day", freq: freq, time: time) { start_level_of_the_day(ctp: false) } + + # Post CTP lotd daily, eotw weekly and cotm monthly + freq = TEST && TEST_CTP_LOTD ? -1 : CTP_LEVEL_FREQUENCY + time = TEST && TEST_CTP_LOTD ? nil : 'ctp_level' + Scheduler.add("CTP level of the day", freq: freq, time: time) { start_level_of_the_day(ctp: true) } + + # Post highscoring report for newest userlevels, and save histories + freq = TEST && TEST_UL_REPORT ? -1 : USERLEVEL_REPORT_FREQUENCY + time = TEST && TEST_UL_REPORT ? nil : 'userlevel_report' + Scheduler.add("Userlevel report", freq: freq, time: time) { send_userlevel_report } if DO_EVERYTHING || REPORT_USERLEVELS || TEST_UL_REPORT + + # Regularly stun nv2 users with a fruit emoji + Scheduler.add("Potato", db: false, log: false) { potato } if POTATO +end + +# Done loading file +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) diff --git a/src/userlevels.rb b/src/userlevels.rb new file mode 100644 index 00000000..a034d92a --- /dev/null +++ b/src/userlevels.rb @@ -0,0 +1,1735 @@ +# This file contains all the userlevel-specific functionality, both internally +# (downloading and parsing, performing rankings, etc), as well as externally +# (interacting with users in the server). Thus, it essentially represents the +# equivalent of both models.rb and messages.rb for userlevel functionality. +# +# There's significant duplication of code between this file and said files, which +# stems from the fact that it was originally made as independent as possible +# to prevent messing up Metanet functionality. Therefore, an interesting (but very +# time consuming) project for the future could be to integrate this file into +# the ones above, as it was done for mappacks from the beginning more recently. + +$load_time = Time.now + +require 'time' +require 'zlib' + +# Contains map data (tiles and objects) in a different table for performance reasons. +class UserlevelData < ActiveRecord::Base +end + +class UserlevelTab < ActiveRecord::Base +end + +# Cache for userlevel queries made using outte's browser which may be sent +# to N++ players via the socket +class UserlevelCache < ActiveRecord::Base + # Compute the key for a query result based on the IDs, which is used to key the cache + def self.key(ids) + str = ids.map{ |id| id.to_s.rjust(7, '0') }.join + sha1(str, c: false, hex: true) + end + + # Remove entries from cache that are either expired or over the allowable limit, + # which are not currently assigned to any user + def self.clean + # Fetch caches not currently assigned to any user + not_used = self.joins('LEFT JOIN users ON userlevel_caches.id = users.query') + .where('query IS NULL') + + # Date of last cache as per cache limit + last_cache = not_used.order(date: :desc).offset(OUTTE_CACHE_LIMIT).first + max_date_1 = last_cache ? last_cache.date : Time.now + + # Date of last cache as per expiration date + max_date_2 = Time.now - OUTTE_CACHE_DURATION + + # Wipe caches below this max date + not_used.where("date <= ?", [max_date_1, max_date_2].max).delete_all + true + rescue => e + lex(e, 'Failed to clean userlevel query cache.') + false + end + + # Assign this query to a user, implying it will be sent to his game + # The same query may be assigned to any number of users + def assign(user) + user.update(query: id) + update(date: Time.now) + end +end + +class UserlevelAuthor < ActiveRecord::Base + has_many :userlevels, foreign_key: :author_id + has_many :userlevel_akas, foreign_key: :author_id + alias_method :akas, :userlevel_akas + + # Parse a userlevel author based on a search term: + # Integer: + # Search as the author ID + # String: + # Search as part of the name, or optionally, also of the aka's (old names) + # Number of results: + # 0 - Raise error of no matches + # 1 - Return author + # <20 - Print matches + # >=20 - Raise error of too many matches + def self.parse(term = nil, aliases = true, event: nil, page: nil) + term = parse_userlevel_author(parse_message(event)) if !term && event + if term.is_a?(Integer) + p = self.find(term) rescue nil + perror("Userlevel author with ID #{verbatim(term)} not found.") if p.nil? + return p + end + perror("Couldn't parse userlevel author.") if !term.is_a?(String) + return nil if term.empty? + p = self.where_like('name', term[0...16]) + multiple = "Authors by \"#{term}\" - Please refine name or use ID instead" + + case p.count + when 0 + perror("No author found by the name #{verbatim(term)}.") if !aliases + p = UserlevelAka.where_like('name', term[0...16]).map(&:author).uniq + case p.count + when 0 + perror("No author found by the name (current or old) #{verbatim(term)}.") + when 1 + return p.first + else + perror("Too many author matches! (#{p.count}). Please refine author name.") if !event + pager(event, page, header: multiple, list: p, rails: true, pluck: [:id, :name]){ |s| + "#{"%6d" % s[0]} - #{s[1]}" + } + end + when 1 + return p.first + else + perror("Too many author matches! (#{p.count}). Please refine author name.") if !event + pager(event, page, header: multiple, list: p, rails: true, pluck: [:id, :name]){ |s| + "#{"%6d" % s[0]} - #{s[1]}" + } + end + rescue => e + lex(e, 'Failed to parse userlevel author.') + nil + end + + # Add an A.K.A. to the author (old name) + def aka(str, time) + str = INVALID_NAMES.include?(str) ? '' : str + return if !self.akas.where(name: str).empty? + UserlevelAka.create(author_id: self.id, name: str, date: time) + end + + # Change author name, taking restrictions into account + def rename(str, time = nil) + str = INVALID_NAMES.include?(str) ? '' : str + self.update(name: str) + aka(str, !time.nil? ? time : Time.now.strftime(DATE_FORMAT_MYSQL)) + end +end + +class UserlevelAka < ActiveRecord::Base + belongs_to :userlevel_author, foreign_key: :author_id + alias_method :author, :userlevel_author + alias_method :author=, :userlevel_author= +end + +class UserlevelScore < ActiveRecord::Base + belongs_to :userlevel + belongs_to :userlevel_player, foreign_key: :player_id + alias_method :player, :userlevel_player + alias_method :player=, :userlevel_player= + + def self.newest(id = Userlevel.min_id) + self.where("userlevel_id >= #{id}") + end + + def self.global + self + end + + def self.retrieve_scores(full, mode = nil, author_id = nil) + scores = full ? self.global : self.newest + if !mode.nil? || !author_id.nil? + scores = scores.joins("INNER JOIN userlevels ON userlevels.id = userlevel_scores.userlevel_id") + if !mode.nil? + scores = scores.where("userlevels.mode = #{mode.to_i}") + end + if !author_id.nil? + scores = scores.where("userlevels.author_id = #{author_id.to_i}") + end + end + scores + end + + # Count top20 scores for all userlevels and fill their completions field + # Delicate pure SQL, doable in Rails? + def self.seed_completions + query = %{ + UPDATE + userlevels, + ( + SELECT userlevel_id AS uid, COUNT(userlevel_id) AS cnt + FROM userlevel_scores + GROUP BY userlevel_id + ORDER BY uid ASC + ) t + SET userlevels.completions = t.cnt + WHERE userlevels.completions < 20 AND userlevels.id = t.uid; + }.gsub(/\s+/, ' ').strip + sql(query) + Userlevel.where(completions: nil).update_all(completions: 0) + end + + # Download demo on the fly + def demo + replay = get_data(:replay, fast: true, replay_id: replay_id, qt: 0) + perror("Error downloading userlevel replay") if replay.nil? + perror("Selected replay seems to be missing") if replay.empty? + Demo.parse(replay[16..-1], 'Level') + end +end + +class UserlevelPlayer < ActiveRecord::Base + has_many :userlevel_scores, foreign_key: :player_id + has_many :userlevel_histories, foreign_key: :player_id + alias_method :scores, :userlevel_scores + + def print_name + name.remove("`") + end + + def newest(id = Userlevel.min_id) + scores.where("userlevel_id >= #{id}") + end + + def retrieve_scores(full = false, mode = nil, author_id = nil) + query = full ? scores : newest + return query.order(:rank) if !mode && !author_id + query = query.joins("INNER JOIN `userlevels` ON `userlevels`.`id` = `userlevel_scores`.`userlevel_id`") + query = query.where("`userlevels`.`mode` = #{mode.to_i}") if !!mode + query = query.where("`userlevels`.`author_id` = #{author_id.to_i}") if !!author_id + query.order(:rank) + end + + def range_s(rank1, rank2, ties, full = false, mode = nil, author_id = nil) + t = ties ? 'tied_rank' : 'rank' + retrieve_scores(full, mode, author_id).where("`#{t}` >= #{rank1} AND `#{t}` <= #{rank2}") + end + + def range_h(rank1, rank2, ties, full = false, mode = nil, author_id = nil) + range_s(rank1, rank2, ties, full, mode, author_id).group_by(&:rank).sort_by(&:first) + end + + def top_ns(rank, ties, full = false, mode = nil, author_id = nil) + range_s(0, rank - 1, ties, full, mode, author_id) + end + + def top_n_count(rank, ties, full = false, mode = nil, author_id = nil) + top_ns(rank, ties, full, mode, author_id).count + end + + def range_n_count(a, b, ties, full = false, mode = nil, author_id = nil) + range_s(a, b, ties, full, mode, author_id).count + end + + def points(ties, full = false, mode = nil, author_id = nil) + retrieve_scores(full, mode, author_id).sum(ties ? '20 - `tied_rank`' : '20 - `rank`') + end + + def avg_points(ties, full = false, mode = nil, author_id = nil) + retrieve_scores(full, mode, author_id).average(ties ? '20 - `tied_rank`' : '20 - `rank`') + end + + def total_score(full = false, mode = nil, author_id = nil) + retrieve_scores(full, mode, author_id).sum(:score).to_f / 60 + end + + def avg_lead(ties, full = false, mode = nil, author_id = nil) + ss = top_ns(1, ties, full, mode, author_id) + count = ss.length + avg = count == 0 ? 0 : ss.map{ |s| + entries = s.userlevel.scores.map(&:score) + (entries[0].to_i - entries[1].to_i).to_f / 60.0 + }.sum.to_f / count + avg || 0 + end +end + +class UserlevelHistory < ActiveRecord::Base + belongs_to :userlevel_player, foreign_key: :player_id + alias_method :player, :userlevel_player + alias_method :player=, :userlevel_player= + + # Transform rankings to history format ready to be created + def self.compose(rankings, rank, time) + rankings.select{ |r| r[1] > 0 }.map do |r| + { + timestamp: time, + rank: rank, + player_id: r[0], + count: r[1] + } + end + end + + # Compare current rankings with historic ones and compute differences + def self.compare(r, time) + type = r == -1 ? :points : :rank + ties = r == 1 + + # Fetch relevant histories to compare against + last = where('timestamp <= ?', time).maximum(:timestamp) + histories = where(timestamp: last - 3600 .. last) + + # Fetch current and old rankings, compute differences + ranking = Userlevel.rank(type, ties, r - 1).map.with_index{ |e, rank| [rank, *e] } + ranking_prev = histories.where(rank: r) + .order('`count` DESC, LOWER(`name`) ASC') + .joins('INNER JOIN `userlevel_players` ON `userlevel_players`.`id` = `player_id`') + .pluck(:player_id, :count) + .map.with_index{ |e, rank| [rank, *e] } + diffs = ranking.map{ |rank, id, count, name| + old_rank, old_id, old_count = ranking_prev.find{ |old_rank, old_id, old_count| id == old_id } + old_rank ? { rank: old_rank - rank, score: count - old_count } : nil + } + + # Find padding and format leaderboard + pad_name = ranking.map(&:last).map(&:length).max + pad_count = ranking.map{ |o| o[2].to_s.length }.max + pad_rank = [diffs.compact.map{ |c| c[:rank].abs.to_s.length }.max.to_i, 2].max + pad_change = diffs.compact.map{ |c| c[:score].abs.to_s.length }.max.to_i + ranking = ranking.map.with_index{ |p, i| + diff = '' + score = "#{"%02d" % i}: #{format_string(p[3], pad_name)} - #{"%#{pad_count}d" % p[2]}" + Highscoreable.format_diff_change(diffs[i], diff, true, pad_rank, pad_change) + "#{score} #{diff}" + }.join("\n") + + ranking + end + + # Generate the highscoring report for a specific ranking type + def self.report(type = 1, footer: true, date: nil) + word = type > 0 ? format_rank(type).pluralize : 'points' + word += ' w/ ties' if type == 1 + emoji = type == 1 ? '🥇' : type > 1 ? '🏆' : '📊' + header = mdtext("#{emoji} Userlevel #{word} report", header: 2) + diff = format_block(compare(type, date || Time.now - 12 * 60 * 60)) + footer = footer ? "\n-# Only includes the newest #{USERLEVEL_REPORT_SIZE} _scored_ userlevels." : '' + header + "\n" + diff + footer + end +end + +class Userlevel < ActiveRecord::Base + include Downloadable + include Highscoreable + include Map + include Levelish + alias_attribute :name, :title + has_many :mappack_levels + has_many :userlevel_scores + belongs_to :userlevel_author, foreign_key: :author_id + alias_method :author, :userlevel_author + alias_method :author=, :userlevel_author= + alias_method :scores, :userlevel_scores + create_enum(:mode, [:solo, :coop, :race]) + # Attributes: + # id - ID of the userlevel in Metanet's database (and ours) + # author_id - Map author user ID in Metanet's database (and ours) + # title - Map title + # favs - Number of favourites / ++s + # date - Date of publishing, UTC times + # mode - Playing mode [0 - Solo, 1 - Coop, 2 - Race] + # score_update - When the scores were last updated in the db + # map_update - When the map properties (like favs) were last updated in the db + # tiles - Tile data compressed in zlib, stored in userlevel_data + # objects - Object data compressed in zlib, stored in userlevel_data + # Note: For details about how map data is stored, see the encode_ and decode_ methods below. + + # TODO: Optimize this in a single query + def self.dump_csv + count = self.count + csv = "id,author_id,author,title,favs,date,mode\n" + csv << self.all.each_with_index.map{ |m, i| + dbg("Dumping userlevel #{"%6d" % [i + 1]} / #{count}...", pad: true, newline: false) + "#{m.id},#{m.author_id},#{m.author.name.tr(',', ';')},#{m.title.tr(',', ';')},#{m.favs},#{m.date.strftime(DATE_FORMAT_OUTTE)},#{m.mode.to_i}" + }.join("\n") + Log.clear + csv + end + + def self.mode(mode) + mode == -1 ? Userlevel : Userlevel.where(mode: mode) + end + + def self.tab(qt, mode = -1) + query = Userlevel::mode(mode) + query = query.joins('INNER JOIN userlevel_tabs ON userlevel_tabs.userlevel_id = userlevels.id') + .where("userlevel_tabs.qt = #{qt}") if qt != QT_NEWEST + query + end + + def self.serial(maps) + maps.map{ |m| + { + id: m.id, + author: (m.author.name rescue ""), + title: m.title, + date: m.date.strftime(DATE_FORMAT_OUTTE), + favs: m.favs + } + } + end + + def self.get_levels(qt = QT_NEWEST, page = 0, mode = MODE_SOLO) + get_data(:levels, qt: qt, mode: mode, page: page) + end + + # For compatibility with Level, Episode and Story + def self.mappack + Userlevel + end + + #----------------------------------------------------------------------------# + # USERLEVEL QUERY DOCUMENTATION | + #----------------------------------------------------------------------------# + # File structure | + # Global header (48B) | + # Map headers (44B each) | + # Map data blocks | + #----------------------------------------------------------------------------# + # Global header structure | + # 16B - Query date in %Y-%m-%d-%H:%M format, seems approximate | + # 4B - Map count | + # 4B - Page number | + # 4B - Type (0 = Level, 1 = Episode, 2 = Story). Always 0. | + # 4B - Query type (0 - 37), see QT_ enum | + # 4B - Game mode (0 = Solo, 1 = Coop, 2 = Race, 3 = HC) | + # 4B - Cache duration in seconds (usually 5 or 1200) | + # 4B - Max page size (usually 500, sometimes 25) | + # 4B - Unknown field (usually 0 or 5) | + #----------------------------------------------------------------------------# + # Map header structure | + # 4B - Userlevel ID (first one is 22715) | + # 4B - Author ID, -1 if not found | + # 16B - Author name, truncated | + # 4B - Favourite (++) count | + # 16B - Date in %Y-%m-%d-%H:%M format | + #----------------------------------------------------------------------------# + # Map data block structure | + # 4B - Block length, including 6B mini-header | + # 2B - Object count | + # Rest - Zlib-compressed map data (see dump_level for documentation) | + #----------------------------------------------------------------------------# + + # Parse binary file with userlevel collection received from N++'s server + # Returns true if there are more pages left to parse + def self.parse(buffer) + buffer = StringIO.new(buffer.to_s) + return false if buffer.size <= 48 + + # Parse header (48B) + header = Struct.new(:date, :count, :page, :type, :qt, :mode, :cache, :max, :unknown) + .new(*ioparse(buffer, 'a16l<8')) + return false if !USERLEVEL_TABS.key?(header.qt) || header.count <= 0 + + # Parse map headers (44B each) + rawMap = Struct.new(:id, :author_id, :author, :favs, :date, :count, :title, :tiles, :objects) + maps = Array.new(header.count).map do + rawMap.new(*ioparse(buffer, 'l<2a16l e + # If there's an exception we return nil as opposed to false in order to be + # able to distinguish it from reaching the final page. + lex(e, 'Error updating userlevels') + nil + end + + # Dump 48 byte header used by the game for userlevel queries + def self.query_header( + count, # Map count + page: 0, # Query page + type: TYPE_LEVEL, # Type (0 = levels, 1 = episodes, 2 = stories) + qt: QT_SEARCH_BY_TITLE, # Query type (36 = search by title) + mode: MODE_SOLO, # Mode (0 = solo, 1 = coop, 2 = race, 3 = hc) + cache: NPP_CACHE_DURATION, # Cache duration in seconds (def. 5, unused for searches) + max: QUERY_LIMIT_HARD # Max results per page (def. 500) + ) + header = Time.now.strftime(DATE_FORMAT_NPP).b + header += [count, page, type, qt, mode, cache, max, 0].pack('l<8') + end + + # Dump binary file containing a collection of userlevels using the format + # of query results that the game utilizes + # (see self.parse for documentation of this format) + # TODO: Add more integrity checks (category...) + def self.dump_query(maps, mode, qt: QT_SEARCH_BY_TITLE) + # Integrity checks + perror("Some of the queried userlevels have an incorrect game mode.") if maps.any?{ |m| MODES.invert[m.mode] != mode } + if maps.size > QUERY_LIMIT_HARD + maps = maps.take(QUERY_LIMIT_HARD) + alert("Too many queried userlevels, truncated to #{QUERY_LIMIT_HARD} maps.") + end + + # Compose query result + header = query_header(maps.size, mode: mode, qt: qt) + headers = maps.map{ |m| m.dump_header }.join + data = maps.map{ |m| m.dump_data }.join + header + headers + data + end + + # Handle intercepted N++'s userlevel queries via the socket + def self.search(req) + # Parse request parameters + params = req.query + search = params['search'].tr('+', '').strip + mode = params['mode'].to_i + + # Integrity checks + return (CLE_FORWARD ? forward(req) : nil) if search != 'outte' + player = Player.find_by(steam_id: params['steam_id']) + return (CLE_FORWARD ? forward(req) : nil) if !player + user = player.users(array: false).where.not(query: nil).first + return (CLE_FORWARD ? forward(req) : nil) if !user + query = UserlevelCache.find_by(id: user.query) + return (CLE_FORWARD ? forward(req) : nil) if !query + res = query.result + m = res[32...36].unpack('l<')[0] + return (CLE_FORWARD ? forward(req) : nil) if mode != m + + # Return saved userlevel query + action_inc('http_levels') + res + rescue => e + lex(e, 'Failed to socket userlevel query.') + nil + end + + # Produces the SQL order string, used when fetching maps from the db + def self.sort(order = "", reverse = false) + return "" if !order.is_a?(String) + # possible spellings for each field, to be used for sorting or filtering + # doesn't include plurals (except "favs", read next line) because we "singularize" later + # DO NOT CHANGE FIRST VALUE (its also the column name) + fields = { + :id => ["id", "map id", "map_id", "level id", "level_id"], + :title => ["title", "name"], + #:author => ["author", "player", "user", "person", "mapper"], + :date => ["date", "time", "datetime", "moment", "day", "period"], + :favs => ["favs", "fav", "++", "++'", "favourite", "favorite"] + } + inverted = [:date, :favs] # the order of these fields will be reversed by default + fields.each{ |k, v| + if v.include?(order.strip.singularize) + order = k + break + end + } + return "" if !order.is_a?(Symbol) + # sorting by date and id is equivalent, sans the direction + str = order == :date ? "id" : fields[order][0] + str += " DESC" if inverted.include?(order) ^ reverse + str + end + + def self.min_id + Userlevel.where(scored: true).order(id: :desc).limit(USERLEVEL_REPORT_SIZE).last.id + end + + def self.newest(id = min_id) + self.where("id >= #{id}") + end + + def self.global + self + end + + # find the optimal score / amount of whatever rankings or stat + def self.find_max(rank, global, mode = nil, author_id = nil) + case rank + when :points + query = global ? self.global : self.newest + query = query.where(mode: mode) if !mode.nil? + query = query.where(author_id: author_id) if !author_id.nil? + query = query.count * 20 + global ? query : [query, USERLEVEL_REPORT_SIZE * 20].min + when :avg_points + 20 + when :avg_rank + 0 + when :maxable + self.ties(nil, false, global, true, mode, author_id) + when :maxed + self.ties(nil, true, global, true, mode, author_id) + when :score + query = UserlevelScore.retrieve_scores(global, mode, author_id) + query.where(rank: 0).sum(:score).to_f / 60.0 + else + query = global ? self.global : self.newest + query = query.where(mode: mode) if !mode.nil? + query = query.where(author_id: author_id) if !author_id.nil? + query = query.count + global ? query : [query, USERLEVEL_REPORT_SIZE].min + end + end + + def self.find_min(full, mode = nil, author_id = nil) + limit = 0 + if full + if author_id.nil? + limit = MIN_G_SCORES + else + limit = MIN_U_SCORES + end + else + if author_id.nil? + limit = MIN_U_SCORES + else + limit = 0 + end + end + limit + end + + def self.spreads(n, small = false, player_id = nil, full = false) + scores = full ? UserlevelScore.global : UserlevelScore.newest + bench(:start) if BENCHMARK + # retrieve player's 0ths if necessary + ids = scores.where(rank: 0, player_id: player_id).pluck(:userlevel_id) if !player_id.nil? + # retrieve required scores and compute spreads + ret1 = scores.where(rank: 0) + ret1 = ret1.where(userlevel_id: ids) if !player_id.nil? + ret1 = ret1.pluck(:userlevel_id, :score).to_h + ret2 = scores.where(rank: n) + ret2 = ret2.where(userlevel_id: ids) if !player_id.nil? + ret2 = ret2.pluck(:userlevel_id, :score).to_h + ret = ret2.map{ |id, s| [id, ret1[id] - s] } + .sort_by{ |id, s| small ? s : -s } + .take(NUM_ENTRIES) + .to_h + # retrieve player names + pnames = scores.where(userlevel_id: ret.keys, rank: 0) + .joins("INNER JOIN `userlevel_players` ON `userlevel_players`.`id` = `userlevel_scores`.`player_id`") + .pluck('`userlevel_scores`.`userlevel_id`', '`userlevel_players`.`name`') + .to_h + ret = ret.map{ |id, s| [id.to_s, s / 60.0, pnames[id]] } + bench(:step) if BENCHMARK + ret + end + + # @par player_id: Excludes levels in which the player is tied for 0th + # @par maxed: Whether we are computing maxed or maxable levels + # @par full: Whether we use all userlevels or only the newest ones + # @par count: Whether to query all info or only return the map count + # @par mode: 0 = Solo, 1 = Coop, 2 = Race, nil = All + # @par author_id: Include only maps by this author + def self.ties(player_id = nil, maxed = false, full = false, count = false, mode = nil, author_id = nil) + bench(:start) if BENCHMARK + scores = UserlevelScore.retrieve_scores(full, mode, author_id) + # retrieve most tied for 0th leves + ret = scores.where(tied_rank: 0) + .group(:userlevel_id) + .order(!maxed ? 'COUNT(`userlevel_scores`.`id`) DESC' : '', :userlevel_id) + .having('COUNT(`userlevel_scores`.`id`) >= 3') + .having(!player_id.nil? ? '`amount` = 0' : '') + .pluck('`userlevel_id`', 'COUNT(`userlevel_scores`.`id`)', !player_id.nil? ? "COUNT(IF(`player_id` = #{player_id}, `player_id`, NULL)) AS `amount`" : '1') + .map{ |s| s[0..1] } + .to_h + # retrieve total score counts for each level (to compare against the tie count and determine maxes) + counts = scores.where(userlevel_id: ret.keys) + .group(:userlevel_id) + .order('COUNT(`userlevel_scores`.`id`) DESC') + .count(:id) + if !count + # retrieve player names owning the 0ths on said level + pnames = scores.where(userlevel_id: ret.keys, rank: 0) + .joins("INNER JOIN `userlevel_players` ON `userlevel_players`.`id` = `userlevel_scores`.`player_id`") + .pluck('`userlevel_scores`.`userlevel_id`', '`userlevel_players`.`name`') + .to_h + # retrieve userlevels + userl = Userlevel.where(id: ret.keys) + .map{ |u| [u.id, u] } + .to_h + ret = ret.map{ |id, c| [userl[id], c, counts[id], pnames[id]] } + else + ret = maxed ? ret.count{ |id, c| counts[id] == c } : ret.count + end + bench(:step) if BENCHMARK + ret + end + + def self.rank(type, ties = false, par = nil, full = false, global = false, author_id = nil) + scores = global ? UserlevelScore.global : UserlevelScore.newest + if !author_id.nil? + ids = Userlevel.where(author_id: author_id).pluck(:id) + scores = scores.where(userlevel_id: ids) + end + + bench(:start) if BENCHMARK + case type + when :rank + scores = scores.where(par < 19 ? "`#{ties ? "tied_rank" : "rank"}` #{par == 0 ? '=' : '<='} #{par}" : '') + .group(:player_id) + .order('`count_id` DESC') + .count(:id) + when :tied + scores_w = scores.where("`tied_rank` #{par == 0 ? '=' : '<='} #{par}") + .group(:player_id) + .order('`count_id` DESC') + .count(:id) + scores_wo = scores.where("`rank` #{par == 0 ? '=' : '<='} #{par}") + .group(:player_id) + .order('`count_id` DESC') + .count(:id) + scores = scores_w.map{ |id, count| [id, count - scores_wo[id].to_i] } + .sort_by{ |id, c| -c } + when :points + scores = scores.group(:player_id) + .order("SUM(#{ties ? "20 - `tied_rank`" : "20 - `rank`"}) DESC") + .sum(ties ? "20 - `tied_rank`" : "20 - `rank`") + when :avg_points + scores = scores.select("COUNT(`player_id`)") + .group(:player_id) + .having("COUNT(`player_id`) >= #{find_min(global, nil, author_id)}") + .order("AVG(#{ties ? "20 - `tied_rank`" : "20 - `rank`"}) DESC") + .average(ties ? "20 - `tied_rank`" : "20 - `rank`") + when :avg_rank + scores = scores.select("COUNT(`player_id`)") + .group(:player_id) + .having("COUNT(`player_id`) >= #{find_min(global, nil, author_id)}") + .order("AVG(`#{ties ? "tied_rank" : "rank"}`)") + .average(ties ? "`tied_rank`" : "`rank`") + when :avg_lead + scores = scores.where(rank: [0, 1]) + .pluck(:player_id, :userlevel_id, :score) + .group_by{ |s| s[1] } + .reject{ |u, s| s.count < 2 } + .map{ |u, s| [s[0][0], s[0][2] - s[1][2]] } + .group_by{ |s| s[0] } + .map{ |p, s| [p, s.map(&:last).sum / (60.0 * s.map(&:last).count)] } + .sort_by{ |p, s| -s } + when :score + scores = scores.group(:player_id) + .order("SUM(`score`) DESC") + .sum('`score` / 60') + end + bench(:step) if BENCHMARK + + scores = scores.take(NUM_ENTRIES) if !full + + # Find all players in advance (better performance) + players = UserlevelPlayer.where(id: scores.map(&:first)).pluck(:id, :name).to_h + scores = scores.map{ |id, count| [id, count, players[id]] } + scores.reject!{ |id, count, name| count <= 0 } unless type == :avg_rank + + # Sort ONLY ties alphabetically by player + scores.sort!{ |a, b| (a[1] <=> b[1]) != 0 ? 0 : a[2].downcase <=> b[2].downcase } + + bench(:step) if BENCHMARK + scores + end + + # Technical + def self.sanitize(string, par) + sanitize_sql_for_conditions([string, par]) + end + + # For compatibility + def map + self + end + + def vanilla + self + end + + def data + UserlevelData.find(self.id) + end + + def tile_data(**kwargs) + data.tile_data + end + + def object_data(**kwargs) + data.object_data + end + + # Generate compressed map dump in the format the game uses when browsing + def dump_data + block = self.dump_level(query: true) + dblock = Zlib::Deflate.deflate(block, 9) + ocount = (block.size - 0xB0 - 966 - 80) / 5 + + data = _pack(dblock.size + 6, 4) # Length of full data block (4B) + data += _pack(ocount, 2) # Object count (2B) + data += dblock # Zlib-compressed map data (?B) + data + end + + # Generate 44 byte map header of the dump above + def dump_header + author_str = (to_ascii(author.name.to_s)[0...16] rescue "").ljust(16, "\x00").b + date_str = date.strftime(DATE_FORMAT_NPP).ljust(16, "\x00").b + + header = _pack(id, 4) # Userlevel ID ( 4B) + header += _pack(author_id, 4) # User ID ( 4B) + header += author_str # User name (16B) + header += _pack(favs, 4) # Map ++'s ( 4B) + header += date_str # Map date (16B) + header + end +end + +# <----------------------------------------------------------------------------> +# <--- MESSAGES ---> +# <----------------------------------------------------------------------------> + +def format_userlevels(maps, page, pagesize: PAGE_SIZE, color: false) + return "" if maps.size == 0 + maps = Userlevel::serial(maps) + + # Calculate required column padding + max_padding = {n: 6, id: 6, title: 30, author: 16, date: 16, favs: 4 } + min_padding = {n: 1, id: 2, title: 5, author: 6, date: 16, favs: 2 } + def_padding = {n: 3, id: 6, title: 25, author: 16, date: 16, favs: 2 } + if !maps.nil? && !maps.empty? + n_padding = [ [ (pagesize * (page - 1) + maps.size).to_s.length, max_padding[:n] ].min, min_padding[:n] ].max + id_padding = [ [ maps.map{ |map| map[:id].to_i }.max.to_s.length, max_padding[:id] ].min, min_padding[:id] ].max + title_padding = [ [ maps.map{ |map| map[:title].to_s.length }.max, max_padding[:title] ].min, min_padding[:title] ].max + author_padding = [ [ maps.map{ |map| map[:author].to_s.length }.max, max_padding[:title] ].min, min_padding[:author] ].max + date_padding = [ [ maps.map{ |map| map[:date].to_s.length }.max, max_padding[:date] ].min, min_padding[:date] ].max + favs_padding = [ [ maps.map{ |map| map[:favs].to_i }.max.to_s.length, max_padding[:favs] ].min, min_padding[:favs] ].max + padding = {n: n_padding, id: id_padding, title: title_padding, author: author_padding, date: date_padding, favs: favs_padding } + else + padding = def_padding + end + + # Print header + output = ANSI.under + ANSI.bold + output += "%-#{padding[:n]}s " % "N" + output += "%-#{padding[:id]}s " % "ID" + output += "%-#{padding[:title]}s " % "TITLE" + output += "%-#{padding[:author]}s " % "AUTHOR" + output += "%-#{padding[:date]}s " % "DATE" + output += "%-#{padding[:favs]}s" % "++" + output += ANSI.clear + "\n" + #output += "-" * (padding.inject(0){ |sum, pad| sum += pad[1] } + padding.size - 1) + "\n" + + colors = { n: ANSI.red, id: ANSI.yellow, title: ANSI.blue, author: ANSI.cyan, date: ANSI.magenta, favs: ANSI.green } + + # Print levels + if maps.nil? || maps.empty? + output += " " * (padding.inject(0){ |sum, pad| sum += pad[1] } + padding.size - 1) + "\n" + else + maps.each_with_index{ |m, i| + line = color ? ANSI.red : '' + line += "%#{padding[:n]}.#{padding[:n]}s " % (pagesize * (page - 1) + i + 1).to_s + line += ANSI.reset if color + padding.reject{ |k, v| k == :n }.each{ |k, v| + line += colors[k] if color + if m[k].is_a?(Integer) + line += "%#{padding[k]}.#{padding[k]}s " % [m[k].to_s] + else + line += "%-#{padding[k]}.#{padding[k]}s " % m[k].to_s.gsub('```', '') + end + line += ANSI.reset if color + } + output << line + "\n" + } + end + format_block(output) +end + +# The next function queries userlevels from the database based on a number of +# parameters, like the title, the author, the tab and the mode, as well as +# allowing for arbitrary orders. +# +# Parameters: +# The parameters are only used when the function has been called by interacting +# with a pre-existing post, in which case we parse the header of the message as +# though it was a user command, to figure out the original query, and then modify +# it by the values of the parameters (e.g. incrementing the page). +# +# Therefore, be CAREFUL when modifying the header of the message. It must still +# be a valid regex command containing all necessary info. +def send_userlevel_browse( + event, # Calling event + name: nil, # Title query + author: nil, # Author name query + page: nil, # Page offset, for button page navigation + order: nil, # Chosen orden from select menu + reverse: nil, # Reverse the order + tab: nil, # Chosen tab from select menu + mode: nil, # Chosen mode from select menu + query: nil, # Full query, to execute this rather than parse the message + exec: true, # Execute query (otherwise, for interactions, the text will remain) + header: nil, # Text to print before the results + footer: nil # Text to print after the results + ) + + # <------ PARSE all message elements ------> + + bench(:start) if BENCHMARK + # Determine whether this is the initial query (new post) or an interaction + # query (edit post). + initial = parse_initial(event) + reset_page = page.nil? && exec && !initial + msg = query.nil? ? parse_message(event) : '' + is_slash = is_slash?(event) + + # Parse ordering + if is_slash + order = order.to_s + reverse = !!reverse + else + h = parse_order(msg, order) # Updates msg + msg, order, reverse = h[:msg], h[:order], h[:reverse] + end + order_str = Userlevel::sort(order, reverse) + + # Parse title and author queries + if query + search, author = query[:title], query[:author] + elsif is_slash + search = name.to_s + else + search, author, msg = parse_title_and_author(msg, false) + search = search.to_s # Prev func might return int + search = unescape(search) if search.is_a?(String) + author = unescape(author) if author.is_a?(String) + end + author = UserlevelAuthor.parse(author, event: event) + + # Parse mode, tab and page + page = parse_page(msg, page, reset_page) + mode = parse_mode(msg, true) if !mode + mode = MODES.invert[mode.downcase] + cat = QT_NEWEST + USERLEVEL_TABS.each{ |qt, v| cat = qt if (tab || msg) =~ /#{v[:name]}/i } + is_tab = USERLEVEL_TABS.select{ |k, v| v[:update] }.keys.include?(cat) + + #<------ FETCH userlevels ------> + + pagesize = event.channel.type == Discordrb::Channel::TYPES[:dm] ? 20 : 10 + + if exec + # Filter userlevels + if query.nil? + query = Userlevel::tab(cat, mode) + query = query.where(author_id: author.id) if !author.nil? + query = query.where(Userlevel.sanitize("title LIKE ?", "%" + search[0...128] + "%")) if !search.empty? + else + query = query[:query] + end + + # Compute count, page number, total pages, and offset + count = query.count + pag = compute_pages(count, page, pagesize) + + # Order userlevels + query = !order_str.empty? ? query.order(order_str) : (is_tab ? query.order("`index` ASC") : query.order("id DESC")) + + # Fetch userlevels + maps = query.offset(pag[:offset]).limit(pagesize).to_a + else + count = msg[/Results:?[\s\*]*(\d+)/i, 1].to_i + pag = compute_pages(count, page, pagesize) + end + + # <------ FORMAT message ------> + + # CAREFUL reformatting the first two lines of the output message (the header), + # since they are used for parsing the message. When someone interacts with it, + # either by pressing a button or making a selection in the menu, we need to + # modify the query and edit the message. We use the header to figure out what + # the original query was, by parsing it exactly as though it were a user + # message, so it needs to have a format compatible with the regex we use to + # parse commands. I know, genius implementation. + if exec + output = header ? "#{header}\n" : '' + output << "Browsing #{USERLEVEL_TABS[cat][:name]}#{mode == -1 ? '' : ' ' + MODES[mode]} maps" + output << " by #{verbatim(author.name[0...64])} (author id #{verbatim(author.id)})" if !author.nil? + output << " for #{verbatim(search[0...64])}" if !search.empty? + output << " sorted by #{reverse ? "-" : ""}#{!order_str.empty? ? order : (is_tab ? "default" : "date")}." + output << format_userlevels(maps, pag[:page], pagesize: pagesize) + output << (count == 0 ? "\nNo results :shrug:" : "Page: **#{pag[:page]}** / **#{pag[:pages]}**. Results: **#{count}**.") + output << "-# " << footer if footer + else + output = event.message.content + end + + bench(:step) if BENCHMARK + + # <------ SEND message ------> + + # Normalize pars + order_str = "default" if order_str.nil? || order_str.empty? + order_str = order_str.downcase.split(" ").first + order_str = "date" if order_str == "id" || order_str == 'default' && cat == QT_NEWEST + + # Create and fill component collection (View) + view = Discordrb::Webhooks::View.new + if !(initial && count == 0) + cur_emoji = initial ? nil : get_emoji(event, 'button:play:') + interaction_add_action_navigation(view, pag[:page], pag[:pages], 'play', 'Play', (EMOJIS_FOR_PLAY - [cur_emoji]).sample, source: 'browse') + interaction_add_select_menu_order(view, 'browse', order_str, cat != QT_NEWEST) + interaction_add_select_menu_tab(view, 'browse', USERLEVEL_TABS[cat][:name]) + interaction_add_select_menu_mode(view, 'browse', MODES[mode], false) + end + + send_message(event, content: output, components: view) +rescue => e + lex(e, 'Error browsing userlevels.', event: event) +end + +# Add an existing userlevel query result to the cache, so that it can be sent +# via the socket to those players which specify so +# Returns whether the cache creation and assignment was successful +# TODO: Find a way to parse the mode directly from the select menu +def send_userlevel_cache(event) + # Parse message for userlevel IDs and mode + msg = parse_message(event, clean: false) + header = msg.split('```')[0].gsub(/(for|by) .*/, '') + ids = msg[/```(.*)```/m, 1].strip.split("\n")[1..-1].map{ |l| l[/\d+\s+(\d+)/, 1].to_i } + mode = MODES.invert[parse_mode(header, true)] + user = parse_user(event.user) + + # User must be identified + if !user.player + modal( + event, + title: 'I don\'t know who you are.', + custom_id: 'identify', + label: 'Enter your N++ player name:', + placeholder: 'Player name', + required: true + ) + return false + end + + # If query is already cached, assign it to this user + key = UserlevelCache.key(ids) + cache = UserlevelCache.find_by(key: key) + if cache + cache.assign(user) + send_userlevel_browse(event, exec: false) + return true + end + + # If query wasn't cached, fetch userlevels and build response + maps = Userlevel.where(id: ids).order("FIND_IN_SET(id, '#{ids.join(',')}')") + result = Userlevel.dump_query(maps, mode) + + # Store query result in cache + UserlevelCache.create(key: key, result: result).assign(user) + send_userlevel_browse(event, exec: false) + true +rescue => e + lex(e, 'Error updating userlevel cache.', event: event) + false +end + +# Wrapper for functions that need to be execute in a single userlevel +# (e.g. download, screenshot, scores...) +# This will parse the query, find matches, and: +# 1) If there are no matches, display an error +# 2) If there is 1 match, execute function passed in the block +# 3) If there are multiple matches, execute the browse function +# We pass in the msg (instead of extracting it from the event) +# because it might've been modified by the caller function already. +def send_userlevel_individual(event, msg = nil, userlevel = nil, name: nil, author: nil, &block) + # If we have specific name / author arguments (likely coming from a slash command), + # we craft a regular command string and use that parse the userlevel from it. + # A bit hacky, but that way we can reuse what we already had. + if name || author + level_id = name.to_i if is_num(name) && name.to_i >= MIN_ID + author_id = author.to_i if is_num(author) && author.to_i >= 1000 + msg = (level_id ? "for #{level_id}" : name ? "for \"#{name.tr('"', '')}\"" : '') + msg << (author_id ? " by #{author_id}" : author ? " by \"#{author.tr('"', '')}\"" : '') + end + map = parse_userlevel(event, userlevel, msg: msg) + + case map[:count] + when 0 + send_message(event, content: map[:msg]) + when 1 + yield(map) + else + send_userlevel_browse(event, query: map, header: map[:msg]) + end +end + +def send_userlevel_demo_download(event, name: nil, author: nil, rank: nil, **kwargs) + clean_userlevel_message(event, ['download', 'replay']) + send_userlevel_individual(event, name: name, author: author){ |map| + msg = parse_message(event) + scores = map[:query].scores + perror("There are no scores for this userlevel") if scores.empty? + rank ||= parse_range(msg).first + perror("The rank must be between 0 and #{scores.size - 1} for this board") if !rank.between?(0, scores.size - 1) + score = scores[rank] + output = "Downloading #{rank.ordinalize} score by `#{score.player.name}` " + output += "(#{"%.3f" % [score.score / 60.0]}) in userlevel #{verbatim(map[:query].title)} " + output += "with ID #{verbatim(map[:query].id.to_s)} " + output += "by #{verbatim(map[:query].author.name)} " + output += "from #{verbatim(map[:query].date.strftime('%F'))}" + send_message( + event, + content: format_header(output), + files: [tmp_file(Demo.encode(score.demo), "#{map[:query].id}_#{rank}", binary: true)] + ) + } +rescue => e + lex(e, 'Error fetching userlevel demo download.', event: event) +end + +def send_userlevel_download(event, name: nil, author: nil, **kwargs) + clean_userlevel_message(event, ['download']) + send_userlevel_individual(event, name: name, author: author){ |map| + output = "Downloading userlevel #{verbatim(map[:query].title)} " + output += "with ID #{verbatim(map[:query].id.to_s)} " + output += "by #{verbatim(map[:query].author.name)} " + output += "from #{verbatim(map[:query].date.strftime('%F'))}" + send_message( + event, + content: format_header(output), + files: [tmp_file(map[:query].dump_level, map[:query].id.to_s, binary: true)] + ) + } +rescue => e + lex(e, 'Error fetching userlevel download.', event: event) +end + +# We can pass the actual level instead of parsing it from the message +# This is used e.g. by the random userlevel function +def send_userlevel_screenshot(event, userlevel = nil, name: nil, author: nil, palette: nil, **kwargs) + # Craft message from parameters received if we come from a slash command + clean_userlevel_message(event, ['screenshot']) + h = parse_palette(event, msg: palette ? "palette \"#{palette}\"" : nil) + send_userlevel_individual(event, nil, userlevel, name: name, author: author){ |map| + output = "#{h[:error]}" + output += "Screenshot for userlevel #{verbatim(map[:query].title)} " + output += "with ID #{verbatim(map[:query].id.to_s)} " + output += "by #{verbatim(map[:query].author.name)} " + output += "from #{verbatim(map[:query].date.strftime('%F'))} " + output += "using palette #{verbatim(h[:palette])}:" + bench(:start) if BENCH_IMAGES + screenshot = map[:query].screenshot(h[:palette], file: true) + send_message(event, content: output, files: [screenshot].compact) + } +rescue => e + lex(e, 'Error sending userlevel screenshot.', event: event) +end + +def send_userlevel_scores(event) + clean_userlevel_message(event, ['scores']) + frac = false # parse_frac(msg) + send_userlevel_individual(event){ |map| + map[:query].update_scores if !OFFLINE_STRICT + output = "#{format_frac(frac)} scores for userlevel #{verbatim(map[:query].title)} " + output += "with ID #{verbatim(map[:query].id.to_s)} " + output += "by #{verbatim(map[:query].author.name)} " + output += "from #{verbatim(map[:query].date.strftime('%F'))}" + count = map[:query].completions + header = format_header(output) + boards = format_block(map[:query].format_scores(frac: frac)) + footer = count && count >= 20 ? "Scores: **#{count}**" : '' + event << header + boards + footer + } +rescue => e + lex(e, 'Error sending userlevel scores.', event: event) +end + +def send_userlevel_rankings(event) + msg = parse_message(event) + rank = parse_rank(msg) || 1 + rank = 1 if rank < 0 + rank = 20 if rank > 20 + ties = parse_ties(msg) + full = parse_full(msg) + global = parse_global(msg) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + type = "" + + if msg =~ /average/i + if msg =~ /point/i + top = Userlevel.rank(:avg_points, ties, nil, full, global, author_id) + type = "average points" + max = Userlevel.find_max(:avg_points, global, nil, author_id) + elsif msg =~ /lead/i + top = Userlevel.rank(:avg_lead, nil, nil, full, global, author_id) + type = "average lead" + max = nil + else + top = Userlevel.rank(:avg_rank, ties, nil, full, global, author_id) + type = "average rank" + max = Userlevel.find_max(:avg_rank, global, nil, author_id) + end + elsif msg =~ /point/i + top = Userlevel.rank(:points, ties, nil, full, global, author_id) + type = "total points" + max = Userlevel.find_max(:points, global, nil, author_id) + elsif msg =~ /score/i + top = Userlevel.rank(:score, nil, nil, full, global, author_id) + type = "total score" + max = Userlevel.find_max(:score, global, nil, author_id) + elsif msg =~ /tied/i + top = Userlevel.rank(:tied, ties, rank - 1, full, global, author_id) + type = "tied #{format_rank(rank)}" + max = Userlevel.find_max(:rank, global, nil, author_id) + else + top = Userlevel.rank(:rank, ties, rank - 1, full, global, author_id) + type = format_rank(rank) + max = Userlevel.find_max(:rank, global, nil, author_id) + end + + count = top.size + score_padding = top.map{ |r| r[1].to_i.to_s.length }.max + name_padding = top.map{ |r| r[2].length }.max + fmt = top[0][1].is_a?(Integer) ? "%#{score_padding}d" : "%#{score_padding + 4}.3f" + top = top.each_with_index.map{ |p, i| + "#{"%02d" % i}: #{format_string(p[2], name_padding)} - #{fmt % p[1]}" + }.join("\n") + footer = "Minimum number of scores required: #{Userlevel.find_min(global, nil, author_id)}" if msg =~ /average/i + + full = format_full(full) + global = format_global(global) + ties = format_ties(ties) + header = "Userlevel #{full} #{global} #{type} #{ties} rankings #{format_author(author)} #{format_max(max)} #{format_time}" + event << format_header(header) + count <= 20 ? event << format_block(top) : send_file(event, top, "userlevel-rankings.txt", false) + event << footer if footer +rescue => e + lex(e, 'Error performing userlevel rankings.', event: event) +end + +def send_userlevel_count(event) + msg = parse_message(event) + player = parse_player(event, true) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + full = parse_global(msg) + rank = parse_rank(msg) || 20 + bott = parse_bottom_rank(msg) || 0 + ind = nil + dflt = parse_rank(msg).nil? && parse_bottom_rank(msg).nil? + tabs = parse_tabs(msg) + ties = parse_ties(msg) + tied = parse_tied(msg) + 20.times.each{ |r| ind = r if !!(msg =~ /\b#{r.ordinalize}\b/i) } + + # If no range is provided, default to 0th count + if dflt + bott = 0 + rank = 1 + end + + # If an individual rank is provided, the range has width 1 + if !ind.nil? + bott = ind + rank = ind + 1 + end + + # The range must make sense + if bott >= rank + event << "You specified an empty range! (#{bott.ordinalize}-#{(rank - 1).ordinalize})" + return + end + + # Retrieve score count in specified range + if tied + count = player.range_n_count(bott, rank - 1, true, full, nil, author_id) - player.range_n_count(bott, rank - 1, type, tabs, false, full, nil, author_id) + else + count = player.range_n_count(bott, rank - 1, ties, full, nil, author_id) + end + + # Format range + if bott == rank - 1 + header = "#{bott.ordinalize}" + elsif bott == 0 + header = format_rank(rank) + elsif rank == 20 + header = format_bottom_rank(bott) + else + header = "#{bott.ordinalize}-#{(rank - 1).ordinalize}" + end + + max = Userlevel.find_max(:rank, full, nil, author_id) + ties = format_ties(ties) + tied = format_tied(tied) + full = format_global(full) + + header = "#{player.name} has #{count} out of #{max} #{full} #{tied} #{header} scores #{ties} #{format_author(author)}" + event << format_header(header, close: '.') +rescue => e + lex(e, 'Error getting userlevel highscore count.', event: event) +end + +def send_userlevel_points(event) + msg = parse_message(event) + player = parse_player(event, true) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + ties = parse_ties(msg) + full = parse_global(msg) + max = Userlevel.find_max(:points, full, nil, author_id) + points = player.points(ties, full, nil, author_id) + ties = format_ties(ties) + full = format_global(full) + + header = "#{player.name} has #{points} out of #{max} #{full} userlevel points #{ties} #{format_author(author)}" + event << format_header(header, close: '.') +rescue => e + lex(e, 'Error computing userlevel points.', event: event) +end + +def send_userlevel_avg_points(event) + msg = parse_message(event) + player = parse_player(event, true) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + ties = parse_ties(msg) + full = parse_global(msg) + avg = player.avg_points(ties, full, nil, author_id) + ties = format_ties(ties) + full = format_global(full) + + header = "#{player.name} has #{"%.3f" % avg} average #{full} userlevel points #{ties} #{format_author(author)}" + event << format_header(header, close: '.') +rescue => e + lex(e, 'Error computing userlevel average points.', event: event) +end + +def send_userlevel_avg_rank(event) + msg = parse_message(event) + player = parse_player(event, true) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + ties = parse_ties(msg) + full = parse_global(msg) + avg = 20 - player.avg_points(ties, full, nil, author_id) + ties = format_ties(ties) + full = format_global(full) + + header = "#{player.name} has an average #{"%.3f" % avg} #{full} userlevel rank #{ties} #{format_author(author)}" + event << format_header(header, close: '.') +rescue => e + lex(e, 'Error computing userlevel average rank.', event: event) +end + +def send_userlevel_total_score(event) + msg = parse_message(event) + player = parse_player(event, true) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + full = parse_global(msg) + max = Userlevel.find_max(:score, full, nil, author_id) + score = player.total_score(full, nil, author_id) + full = format_global(full) + + header = "#{player.name}'s total #{full} userlevel score is #{"%.3f" % score} out of #{"%.3f" % max} #{format_author(author)}" + event << format_header(header, close: '.') +rescue => e + lex(e, 'Error computing userlevel total score.', event: event) +end + +def send_userlevel_avg_lead(event) + msg = parse_message(event) + player = parse_player(event, true) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + ties = parse_ties(msg) + full = parse_global(msg) + avg = player.avg_lead(ties, full, nil, author_id) + ties = format_ties(ties) + full = format_global(full) + + header = "#{player.name} has an average #{"%.3f" % avg} #{full} userlevel 0th lead #{ties} #{format_author(author)}" + event << format_header(header, close: '.') +rescue => e + lex(e, 'Error computing userlevel average lead.', event: event) +end + +def send_userlevel_list(event) + msg = parse_message(event) + player = parse_player(event, true) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + rank = parse_rank(msg) || 20 + bott = parse_bottom_rank(msg) || 0 + ties = parse_ties(msg) + full = parse_global(msg) + if rank == 20 && bott == 0 && !!msg[/0th/i] + rank = 1 + bott = 0 + end + res = player.range_s(bott, rank - 1, ties, full, nil, author_id) + .joins("INNER JOIN `userlevels` ON `userlevels`.`id` = `userlevel_scores`.`userlevel_id`") + .pluck("CONCAT(LPAD(`rank`, 2, '0'), ': [', LPAD(`userlevel_id`, 6, ' '), '] ', `title`, ' (', ROUND(`score` / 60.0, 3), ')')") + event << "Total: #{res.count}" + send_file(event, res.join("\n"), "#{full ? "global-" : ""}userlevel-scores-#{player.name}.txt") +rescue => e + lex(e, 'Error getting userlevel highscore list.', event: event) +end + +def send_userlevel_stats(event) + msg = parse_message(event) + player = parse_player(event, true) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + ties = parse_ties(msg) + full = parse_global(msg) + counts = player.range_h(0, 19, ties, full, nil, author_id) + .map{ |rank, scores| [rank, scores.length] } + histogram = make_histogram(counts) + totals = counts.map{ |rank, count| + "#{Highscoreable.format_rank(rank)}: #{" %5d" % count}" + }.join("\n\t") + overall = "Totals: %5d" % counts.reduce(0){ |sum, c| sum += c[1] } + full = format_global(full) + event << format_header("#{full.capitalize} userlevels highscoring stats for #{player.name} #{format_author(author)} #{format_time}") + event << format_block(" Scores\n\t#{totals}\n#{overall}\n#{histogram}") +rescue => e + lex(e, 'Error computing userlevel stats.', event: event) +end + +def send_userlevel_spreads(event) + msg = parse_message(event) + n = (msg[/([0-9][0-9]?)(st|nd|rd|th)/, 1] || 1).to_i + player = parse_player(event, true, true, false) + small = !!(msg =~ /smallest/) + full = parse_global(msg) + perror("I can't show you the spread between 0th and 0th...") if n == 0 + + spreads = Userlevel.spreads(n, small, player.nil? ? nil : player.id, full) + namepad = spreads.map{ |s| s[0].length }.max + scorepad = spreads.map{ |s| s[1] }.max.to_i.to_s.length + 4 + spreads = spreads.each_with_index + .map { |s, i| "#{"%02d" % i}: #{"%-#{namepad}s" % s[0]} - #{"%#{scorepad}.3f" % s[1]} - #{s[2]}"} + .join("\n") + + spread = small ? "smallest" : "largest" + rank = (n == 1 ? "1st" : (n == 2 ? "2nd" : (n == 3 ? "3rd" : "#{n}th"))) + full = format_global(full) + event << format_header("#{full.capitalize} userlevels #{!player.nil? ? "owned by #{player.name}" : ""} with the #{spread} spread between 0th and #{rank}") + event << format_block(spreads) +rescue => e + lex(e, 'Error computing userlevel spreads.', event: event) +end + +def send_userlevel_maxed(event) + msg = parse_message(event) + player = parse_player(event, true, true, false) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + full = parse_global(msg) + ties = Userlevel.ties(player.nil? ? nil : player.id, true, full, false, nil, author_id) + .select { |s| s[1] == s[2] } + .map { |s| "#{"%6d" % s[0].id} - #{"%6d" % s[0].author_id} - #{format_string(s[3])}" } + count = ties.count{ |s| s.length > 1 } + player = player.nil? ? "" : " without " + player.name + full = format_global(full) + block = " ID - Author - Player\n#{ties.join("\n")}" + header = "There are #{count} potentially maxed #{full} userlevels #{format_time} #{player} #{format_author(author)}" + event << format_header(header) + count <= 20 ? event << format_block(block) : send_file(event, block, "maxed-userlevels.txt", false) +rescue => e + lex(e, 'Error computing userlevel maxes.', event: event) +end + +def send_userlevel_maxable(event) + msg = parse_message(event) + player = parse_player(event, true, true, false) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + full = parse_global(msg) + ties = Userlevel.ties(player.nil? ? nil : player.id, false, full, false, nil, author_id) + .select { |s| s[1] < s[2] } + .sort_by { |s| -s[1] } + count = ties.count + ties = ties.take(NUM_ENTRIES).map { |s| + "#{"%6s" % s[0].id} - #{"%4d" % s[1]} - #{"%6d" % s[0].author_id} - #{format_string(s[3])}" + } + player = player.nil? ? "" : " without " + player.name + full = format_global(full) + header = "All #{count} #{full} userlevels with the most ties for 0th #{format_time} #{player} #{format_author(author)}" + event << format_header(header) + event << format_block(" ID - Ties - Author - Player\n#{ties.join("\n")}") +rescue => e + lex(e, 'Error computing userlevel maxables.', event: event) +end + +def send_random_userlevel(event) + msg = parse_message(event) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + amount = [(msg[/\d+/] || 1).to_i, PAGE_SIZE].min + mode = parse_mode(msg, true) + full = parse_global(msg) + maps = full ? Userlevel.global : Userlevel.newest + + maps = maps.where(mode: mode.to_sym) + maps = maps.where(author_id: author_id) if !author_id.nil? + maps = maps.sample(amount) + + if amount > 1 + event << format_header("Random selection of #{amount} #{mode} #{format_global(full)} userlevels #{!author.nil? ? "by #{author.name}" : ""}") + event << format_userlevels(maps, 1) + else + send_userlevel_screenshot(event, maps.first) + end +rescue => e + lex(e, 'Error fetching random userlevel.', event: event) +end + +def send_userlevel_mapping_summary(event) + # Parse message parameters + msg = parse_message(event) + author = UserlevelAuthor.parse(parse_userlevel_both(msg), event: event) + author_id = !author.nil? ? author.id : nil + mode = parse_mode(msg, false, true) + + # Fetch userlevels + maps = Userlevel.global + maps = maps.where(mode: mode.to_sym) if !mode.nil? + maps = maps.where(author_id: author_id) if !author_id.nil? + count = maps.count + + # Perform summary + header = "Userlevel mapping summary#{" for #{author.name}" if !author.nil?}" + stats = "Maps: #{count}\n" + if author.nil? + authors = maps.distinct.count(:author_id) + prolific = maps.group(:author_id).order("COUNT(`id`) DESC").count(:id).first + popular = maps.group(:author_id).order("SUM(`favs`) DESC").sum(:favs).first + refined = maps.group(:author_id).order("AVG(`favs`) DESC").average(:favs).first + stats << "Authors: #{authors}\n" + stats << "Maps / author: #{"%.3f" % (count.to_f / authors)}\n" + stats << "Most maps: #{prolific.last} (#{Userlevel.find_by(author_id: prolific.first).author.name})\n" + stats << "Most ++'s: #{popular.last} (#{Userlevel.find_by(author_id: popular.first).author.name})\n" + stats << "Most avg ++'s: #{"%.3f" % refined.last} (#{Userlevel.find_by(author_id: refined.first).author.name})\n" + end + if !maps.is_a?(Array) || !maps.empty? + first = maps.order(:id).first + stats << "First map: #{first.date.strftime(DATE_FORMAT_OUTTE)} (#{first.id})\n" + last = maps.order(id: :desc).first + stats << "Last map: #{last.date.strftime(DATE_FORMAT_OUTTE)} (#{last.id})\n" + best = maps.order(favs: :desc).first + stats << "Most ++'ed map: #{best.favs} (#{best.id})\n" + sum = maps.sum(:favs).to_i + stats << "Total ++'s: #{sum}\n" + avg = sum.to_f / count + stats << "Avg. ++'s: #{"%.3f" % avg}\n" + end + event << format_header(header) + format_block(stats) +end + +def send_userlevel_highscoring_summary(event) + # Parse message parameters + msg = parse_message(event) + player = parse_player(event, true, true, false) + author = parse_author(event, false) + author_id = !author.nil? ? author.id : nil + full = parse_global(msg) + mode = parse_mode(msg, false, true) + + perror("The global userlevel highscoring summary is disabled for now until it's optimized, you can still do:\n* The newest highscoring summary.\n* The global highscoring summary _for a specific player_.") if full && !player && !author + + # Fetch userlevels + maps = full ? Userlevel.global : Userlevel.newest + maps = maps.where(mode: mode.to_sym) if !mode.nil? + maps = maps.where(author_id: author_id) if !author_id.nil? + count = maps.count + + # Fetch scores + scores = (player.nil? ? UserlevelScore : player).retrieve_scores(full, mode, author_id) + count_a = scores.distinct.count(:userlevel_id) + count_s = scores.count + + # Perform summary + header = "#{format_global(full).capitalize} userlevel highscoring summary #{format_author(author)} #{"for #{player.name}" if !player.nil?}" + stats = "" + if player.nil? + min = full ? MIN_G_SCORES : MIN_U_SCORES + scorers = scores.distinct.count(:player_id) + prolific1 = scores.group(:player_id).order("COUNT(`id`) DESC").count(:id).first + prolific2 = scores.where("`rank` <= 9").group(:player_id).order("COUNT(`id`) DESC").count(:id).first + prolific3 = scores.where("`rank` <= 4").group(:player_id).order("COUNT(`id`) DESC").count(:id).first + prolific4 = scores.where("`rank` = 0").group(:player_id).order("COUNT(`id`) DESC").count(:id).first + highscore = scores.group(:player_id).order("SUM(`score`) DESC").sum(:score).first + manypoint = scores.group(:player_id).order("SUM(20 - `rank`) DESC").sum("20 - `rank`").first + averarank = scores.select("COUNT(`rank`)").group(:player_id).having("COUNT(`rank`) >= #{min}").order("AVG(`rank`)").average(:rank).first + maxes = Userlevel.ties(nil, true, full, true, nil, author_id) + maxables = Userlevel.ties(nil, false, full, true, nil, author_id) + tls = scores.where(rank: 0).sum(:score).to_f / 60.0 + tls_p = highscore.last.to_f / 60.0 + stats << "Scored maps: #{count_a}\n" + stats << "Unscored maps: #{count - count_a}\n" + stats << "Scores: #{count_s}\n" + stats << "Players: #{scorers}\n" + stats << "Scores / map: #{"%.3f" % (count_s.to_f / count)}\n" + stats << "Scores / player: #{"%.3f" % (count_s.to_f / scorers)}\n" + stats << "Total score: #{"%.3f" % tls}\n" + stats << "Avg. score: #{"%.3f" % (tls / count_a)}\n" + stats << "Maxable maps: #{maxables}\n" + stats << "Maxed maps: #{maxes}\n" + stats << "Most Top20s: #{prolific1.last} (#{UserlevelPlayer.find(prolific1.first).name})\n" + stats << "Most Top10s: #{prolific2.last} (#{UserlevelPlayer.find(prolific2.first).name})\n" + stats << "Most Top5s: #{prolific3.last} (#{UserlevelPlayer.find(prolific3.first).name})\n" + stats << "Most 0ths: #{prolific4.last} (#{UserlevelPlayer.find(prolific4.first).name})\n" + stats << "Most total score: #{"%.3f" % tls_p} (#{UserlevelPlayer.find(highscore.first).name})\n" + stats << "Most points: #{manypoint.last} (#{UserlevelPlayer.find(manypoint.first).name})\n" + stats << "Best avg rank: #{averarank.last} (#{UserlevelPlayer.find(averarank.first).name})\n" rescue nil + else + tls = scores.sum(:score).to_f / 60.0 + stats << "Total Top20s: #{count_s}\n" + stats << "Total Top10s: #{scores.where("`rank` <= 9").count}\n" + stats << "Total Top5s: #{scores.where("`rank` <= 4").count}\n" + stats << "Total 0ths: #{scores.where("`rank` = 0").count}\n" + stats << "Total score: #{"%.3f" % tls}\n" + stats << "Avg. score: #{"%.3f" % (tls / count_s)}\n" + stats << "Total points: #{scores.sum("20 - `rank`")}\n" + stats << "Avg. rank: #{"%.3f" % scores.average(:rank)}\n" + end + event << format_header(header) + format_block(stats) +end + +def send_userlevel_summary(event) + msg = parse_message(event) + mapping = !!msg[/mapping/i] + highscoring = !!msg[/highscoring/i] + both = !(mapping || highscoring) + send_userlevel_mapping_summary(event) if mapping || both + send_userlevel_highscoring_summary(event) if highscoring || both +rescue => e + lex(e, 'Error performing userlevel summary.', event: event) +end + +def send_userlevel_trace(event) + perror("Sorry, tracing is disabled.") if !FEATURE_NTRACE + wait_msg = send_message(event, content: 'Queued...', db: false) if $mutex[:trace].locked? + $mutex[:trace].synchronize do + wait_msg.delete if !wait_msg.nil? rescue nil + clean_userlevel_message(event) + send_userlevel_individual(event){ |map| + Map.trace(event, anim: !!parse_message(event)[/anim/i], h: map[:query]) + } + end +rescue => e + lex(e, 'Error performing userlevel trace.', event: event) +end + +def send_userlevel_times(event) + event << "Userlevel database update times:" + + next_level = ($status_update + STATUS_UPDATE_FREQUENCY) - Time.now.to_i + next_level_minutes = (next_level / 60).to_i + next_level_seconds = next_level - (next_level / 60).to_i * 60 + event << "* I'll update the userlevel database in #{next_level_minutes} minutes and #{next_level_seconds} seconds." + + next_level = GlobalProperty.get_next_update('userlevel_score') - Time.now + next_level_hours = (next_level / (60 * 60)).to_i + next_level_minutes = (next_level / 60).to_i - (next_level / (60 * 60)).to_i * 60 + event << "* I'll update the newest userlevel scores in #{next_level_hours} hours and #{next_level_minutes} minutes." + + next_level = GlobalProperty.get_next_update('userlevel_tab') - Time.now + next_level_hours = (next_level / (60 * 60)).to_i + next_level_minutes = (next_level / 60).to_i - (next_level / (60 * 60)).to_i * 60 + event << "* I'll update the userlevel tabs (e.g. hardest) in #{next_level_hours} hours and #{next_level_minutes} minutes." +rescue => e + lex(e, 'Error fetching userlevel update times.', event: event) +end + +def respond_userlevels(event) + msg = parse_message(event) + action_inc('commands') + + # Exclusively global methods + if !msg[NAME_PATTERN, 2] + return send_userlevel_rankings(event) if msg =~ /\brank/i + end + + return send_userlevel_browse(event) if msg =~ /\bbrowse\b/i || msg =~ /\bsearch\b/i + return send_userlevel_screenshot(event) if msg =~ /\bscreenshots?\b/i + return send_userlevel_scores(event) if msg =~ /scores\b/i + return send_userlevel_demo_download(event) if (msg =~ /\breplay\b/i || msg =~ /\bdemo\b/i) && msg =~ /\bdownload\b/i + return send_userlevel_download(event) if msg =~ /\bdownload\b/i + return send_userlevel_trace(event) if msg =~ /\btrace\b/i || msg =~ /\banim/i + return send_userlevel_count(event) if msg =~ /how many/i + return send_userlevel_spreads(event) if msg =~ /spread/i + return send_userlevel_avg_points(event) if msg =~ /average/i && msg =~ /point/i && msg !~ /rank/i + return send_userlevel_points(event) if msg =~ /point/i && msg !~ /rank/i + return send_userlevel_avg_rank(event) if msg =~ /average/i && msg =~ /rank/i && !!msg[NAME_PATTERN, 2] + return send_userlevel_avg_lead(event) if msg =~ /average/i && msg =~ /lead/i && msg !~ /rank/i + return send_userlevel_total_score(event) if msg =~ /total score/i && msg !~ /rank/i + return send_userlevel_list(event) if msg =~ /\blist\b/i + return send_userlevel_stats(event) if msg =~ /stat/i + return send_userlevel_maxed(event) if msg =~ /maxed/i + return send_userlevel_maxable(event) if msg =~ /maxable/i + return send_random_userlevel(event) if msg =~ /random/i + return send_userlevel_summary(event) if msg =~ /summary/i + return send_userlevel_times(event) if msg =~ /\bwhen\b/i + + action_dec('commands') + event << "Sorry, I didn't understand your userlevel command." +end + +# Done loading file +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) diff --git a/src/utils.rb b/src/utils.rb new file mode 100644 index 00000000..5838ae88 --- /dev/null +++ b/src/utils.rb @@ -0,0 +1,3384 @@ +# This file compiles a general library of diverse functions that can be useful +# throughout the entire program: +# (TOC) +# (SEC1) Logging: +# A custom and configurable logging class, with different levels and modes, +# that can log timestamped text to the terminal, to a file, and to Discord. +# (SEC2) Exception handling: +# Defines a custom exception class, OutteError, which is printed to Discord +# whenever raised. Intended for user errors. +# (SEC3) Networking: +# Getting arbitrary data from N++'s server using Steam IDs, forwarding +# requests and acting as a middleman, building multipart POSTs, ... +# (SEC4) System operations: +# Forking, threading, inkoving the shell, getting memory information for +# maintenance, running Python scripts, etc. +# (SEC5) Benchmarking: +# Functions to benchmark code, perform memory profiling, etc. +# (SEC6) String manipulation: +# Converting between formats (ASCII, UTF8, ...), string escaping/unescaping, +# string sanitization (for filenames, SQL, ...), string truncation/padding, +# string distance (Damerau-Levenshtein), etc. +# (SEC7) Binary manipulation: +# Basically packing and unpacking binary data into/from strings. +# (SEC8) File manipulation: +# Helpers for parsing binary files and zipping/unzipping stuff. +# (SEC9) Discord related: +# Finding Discord users, channels, servers, emojis, etc. Pinging users, +# reacting or unreacting to comments, mentioning channels... Formatting +# strings (as code blocks, spoilers, ...), etc. +# (SEC10) N++ specific: +# Stuff like sanitizing parameters for N++ functions, finding maximum values +# for rankings, calculating episode splits, or computing a highscoreable's +# name from its ID. +# (SEC11) Graphics: +# Text-based and image-based graphics generation. Includes things such as +# progress bars, tables, histograms, and plots. +# (SEC12) Bot management: +# Permission system for commands with custom roles, setting the bot's main +# channels, leaving blacklisted servers, restarting the bot, etc. +# (SEC13) SQL: +# Acquire/release db connections, perform raw SQL queries, monitor SQL +# resources, check Rails version, etc. +# (SEC14) Maths: +# Operations (e.g. weighted averages), geometry (e.g. intersection and +# areas of rectangles), hashing (SHA1, MD5), etc. + +$load_time = Time.now + +require 'active_record' +require 'damerau-levenshtein' +require 'digest' +require 'net/http' +require 'timeout' +require 'unicode/emoji' +require 'zip' + + +# <----------------------------------------------------------------------------> +# <------ (SEC1) LOGGING ------> +# <----------------------------------------------------------------------------> + +if LOG_SQL + ActiveRecord::Base.logger = ActiveSupport::Logger.new(STDOUT) + if LOG_SQL_TO_FILE + ActiveRecord::Base.logger.extend( + ActiveSupport::Logger.broadcast( + ActiveSupport::Logger.new(PATH_LOG_SQL) + ) + ) + end +end + +# Custom logging class, that supports: +# - 9 modes (info, error, debug, etc) +# - 5 levels of verbosity (from silent to all) +# - 3 outputs (terminal, file and Discord DMs) +# - Both raw and rich format (colored, unicode, etc) +# - Methods to config it on the fly from Discord +module Log extend self + + attr_reader :modes, :modes_file, :fancy, :socket + + MODES = { + fatal: { long: 'FATAL', short: 'F', fmt: "\x1B[41m" }, # Red background + error: { long: 'ERROR', short: '✗', fmt: "\x1B[31m" }, # Red + warn: { long: 'WARN' , short: '!', fmt: "\x1B[33m" }, # Yellow + good: { long: 'GOOD' , short: '✓', fmt: "\x1B[32m" }, # Green + info: { long: 'INFO' , short: 'i', fmt: "" }, # Normal + msg: { long: 'MSG' , short: 'm', fmt: "\x1B[34m" }, # Blue + in: { long: 'IN' , short: '←', fmt: "\x1B[35m" }, # Magenta + out: { long: 'OUT' , short: '→', fmt: "\x1B[36m" }, # Cyan + debug: { long: 'DEBUG', short: 'D', fmt: "\x1B[90m" } # Gray + } + + LEVELS = { + silent: [], + quiet: [:fatal, :error, :warn], + normal: [:fatal, :error, :warn, :good, :info, :msg], + debug: [:fatal, :error, :warn, :good, :info, :msg, :debug, :in, :out] + } + + BOLD = "\x1B[1m" + RESET = "\x1B[0m" + + @socket = SOCKET_LOG + @fancy = LOG_FANCY + @modes = LEVELS[LOG_LEVEL] || LEVELS[:normal] + @modes_file = LEVELS[LOG_LEVEL_FILE] || LEVELS[:quiet] + + def fmt(str, mode) + "#{MODES[mode][:fmt]}#{str}#{RESET}".force_encoding('UTF-8') + end + + def bold(str) + "#{BOLD}#{str}#{RESET}" + end + + def inf(str) + dbg(str) + str + end + + def level(l) + return inf("Logging level #{l} does not exist") if !LEVELS.key?(l) + @modes = LEVELS[l] + inf("Changed logging level to #{l.to_s}") + rescue + inf("Failed to change logging level") + end + + def change_fancy(enabled = nil) + old_fancy = @fancy + @fancy = !enabled.nil? ? !!enabled : !@fancy + old_fancy == @fancy ? inf("Fancy logs were already #{@fancy}") : @fancy ? inf("Enabled fancy logs") : inf("Disabled fancy logs") + rescue + inf("Failed to change logging fanciness") + end + + def change_socket(enabled = nil) + old_socket = @socket + @socket = !enabled.nil? ? !!enabled : !@socket + old_socket == @socket ? inf("Socket logs were already #{@socket}") : @socket ? inf("Enabled socket logs") : inf("Disabled socket logs") + rescue + inf("Failed to change socket logging") + end + + def set_modes(modes) + @modes = modes.select{ |m| MODES.key?(m) } + inf("Set logging modes to #{@modes.join(', ')}") + rescue + inf("Failed to set logging modes") + end + + def change_modes(modes) + added = [] + removed = [] + modes.each{ |m| + next if !MODES.key?(m) + if !@modes.include?(m) + @modes << m + added << m + else + @modes.delete(m) + removed << m + end + } + ret = [] + ret << "added logging modes #{added.join(', ')}" if !added.empty? + ret << "removed logging modes #{removed.join(', ')}" if !removed.empty? + inf(ret.join("; ").capitalize) + rescue + inf("Failed to change logging modes") + end + + def format(text, mode, app = 'BOT', newline: true, pad: false, progress: false, fancy: true) + # Normalize parameters + mode = :info if !MODES.key?(mode) + pad, newline = true, false if progress + m = MODES[mode] || MODES[:info] + + # Message prefixes (timestamp, symbol, source app) + date = Time.now.strftime(DATE_FORMAT_LOG) + type = { + fancy: bold(fmt(m[:short], mode)), + plain: "[#{m[:long]}]".ljust(7, ' ') + } + app = " (#{app.ljust(3, ' ')[0...3]})" + app = { + fancy: LOG_APPS ? bold(app) : '', + plain: LOG_APPS ? app : '' + } + + # Format text + header = { + fancy: "[#{date}] #{type[:fancy]}#{app[:fancy]} ", + plain: "[#{date}] #{type[:plain]}#{app[:plain]} ", + } + lines = { + fancy: text.split("\n").map{ |l| (header[:fancy] + fmt(l, mode)).strip }, + plain: text.split("\n").map{ |l| (header[:plain] + l).strip } + } + lines = { + fancy: lines[:fancy].map{ |l| l.ljust(LOG_PAD, ' ') }, + plain: lines[:plain].map{ |l| l.ljust(LOG_PAD, ' ') } + } if pad + msg = { + fancy: "\r" + lines[:fancy].join("\n"), + plain: "\r" + lines[:plain].join("\n") + } + end + + # Main function to log text + def write( + text, # The text to log + mode, # The type of log (info, error, debug, etc) + app = 'BOT', # The origin of the log (outte, discordrb, etc) + newline: true, # Add a newline at the end or not + pad: false, # Pad each line of the text to a fixed width + progress: false, # Progress log line (pad + no newline) + fancy: nil, # Use rich logs (color, bold, etc) + console: true, # Log to the console + file: true, # Log to the log file + discord: false, # Log to the botmaster's DMs in Discord + event: nil # Log to the Discord's channel, if any + ) + # Return if we don't need to log anything + mode = :info if !MODES.key?(mode) + log_to_console = LOG_TO_CONSOLE && console && @modes.include?(mode) + log_to_file = LOG_TO_FILE && file && @modes_file.include?(mode) + log_to_discord = LOG_TO_DISCORD && discord + return text if !log_to_console && !log_to_file && !log_to_discord && !event + text = text.to_s.scrub('') + + # Normalize parameters + fancy = @fancy if ![true, false].include?(fancy) + fancy = false if !LOG_FANCY + stream = STDOUT + stream = STDERR if [:fatal, :error, :warn].include?(mode) + pad, newline = true, false if progress + m = MODES[mode] || MODES[:info] + + # Format text + msg = format(text, mode, app, newline: newline, pad: pad, progress: progress, fancy: fancy) + + # Log to the terminal, if specified + if log_to_console + t_msg = fancy ? msg[:fancy] : msg[:plain] + newline ? stream.puts(t_msg) : stream.print(t_msg) + stream.flush + end + + # Log to a file, if specified and possible + if log_to_file + if File.size?(PATH_LOG_FILE).to_i >= LOG_FILE_MAX + File.rename(PATH_LOG_FILE, PATH_LOG_OLD) + alert("Log file was filled!", file: false, discord: true) + end + File.write(PATH_LOG_FILE, msg[:plain].strip + "\n", mode: 'a') + end + + # Log to Discord DMs, if specified + discord(text) if log_to_discord + TmpMsg.update(event, text, temp: false) if event + + # Log occurrence to db + action_inc('logs') + action_inc('errors') if [:error, :fatal].include?(mode) + action_inc('warnings') if mode == :warn + + nil + rescue => e + puts "Failed to log text: #{e.message}" + puts e.backtrace.join("\n") if LOG_BACKTRACES + ensure + exit if mode == :fatal + end + + # Handle exceptions + def exception(e, msg = '', **kwargs) + write(msg, :error, **kwargs) + write(e.message, :error) + write(e.backtrace.join("\n"), :debug) if LOG_BACKTRACES + action_inc('exceptions') + nil + end + + # Send DM to botmaster + def discord(msg) + send_message(botmaster.pm, content: msg) if LOG_TO_DISCORD rescue nil + end + + # Clear the current terminal line + def clear + write(' ' * LOG_PAD, :info, newline: false, pad: true) + end +end + +# Shortcuts for different logging methods +def log (msg, **kwargs) Log.write(msg, :info, **kwargs) end +def alert (msg, **kwargs) Log.write(msg, :warn, **kwargs) end +def err (msg, **kwargs) Log.write(msg, :error, **kwargs) end +def msg (msg, **kwargs) Log.write(msg, :msg, **kwargs) end +def succ (msg, **kwargs) Log.write(msg, :good, **kwargs) end +def dbg (msg, **kwargs) Log.write(msg, :debug, **kwargs) end +def lin (msg, **kwargs) Log.write(msg, :in, **kwargs) end +def lout (msg, **kwargs) Log.write(msg, :out, **kwargs) end +def fatal (msg, **kwargs) Log.write(msg, :fatal, **kwargs) end +def lex (e, msg, **kwargs) Log.exception(e, msg, **kwargs) end +def ld (msg) Log.discord(msg) end + +# Shortcuts for logging bot's status (action counters) to db +def action_inc(key) + GlobalProperty.status_set(key, GlobalProperty.status_get(key) + 1) rescue -1 +end + +def action_dec(key) + GlobalProperty.status_set(key, GlobalProperty.status_get(key) - 1) rescue -1 +end + +# <----------------------------------------------------------------------------> +# <------ (SEC2) EXCEPTION HANDLING ------> +# <----------------------------------------------------------------------------> + +# Custom exception classes. +# Note: We inherit from Exception, rather than StandardError, because that +# way they will go past normal "rescues" + +# Used when there is user error, its message gets sent to Discord by default. +# log - Log message to terminal +# discord - Log message to Discord +class OutteError < Exception + attr_reader :log, :discord, :ephemeral + + def initialize(msg = 'Unknown outte error', log: false, discord: true, ephemeral: false) + @discord = discord + @log = log + @ephemeral = ephemeral + super(msg) + end +end + +def perror(msg = '', log: false, discord: true, ephemeral: false) + raise OutteError.new(msg.to_s, log: log, discord: discord, ephemeral: ephemeral) +end + +# <----------------------------------------------------------------------------> +# <------ (SEC3) NETWORKING ------> +# <----------------------------------------------------------------------------> + +# Get the required server endpoint to perform the desired request to N++ server +def npp_uri(type, steam_id, **args) + # Build path component. + request = case type + when :scores + METANET_GET_SCORES + when :replay + METANET_GET_REPLAY + when :levels + METANET_GET_LEVELS + when :search + METANET_GET_SEARCH + when :submit + METANET_POST_SCORE + when :publish + METANET_POST_LEVEL + when :login + METANET_POST_LOGIN + else + return + end + path = METANET_PATH + '/' + request + + # Build query component. We always add 2 default attributes plus the Steam ID. + args = { app_id: APP_ID, steam_auth: '', steam_id: steam_id }.merge(args) + query = URI.encode_www_form(args) + + # Build full URI + URI::HTTPS.build(host: METANET_HOST, path: path, query: query) +end + +# Make a GET request to N++'s server. We need to find an authenticated player +# to do so. This doesn't make sense for POST requests since they're dependent +# on the player used. Requests available: +# :scores => Download the leaderboards (JSON) +# :replay => Download raw demo data (binary, zlibbed) +# :levels => Download a userlevel page (binary) +# :search => Download a page of searched userlevels (idem) +# Arguments: +# fast: Only try players that have been active in the last week +# auth: Only try players for whom we have steam auth tickets saved (obsolete) +def get_data(type, fast: false, auth: true, **args) + # Proc to perform request for a specific player, returns from get_data if successful + request = Proc.new{ |player| + res = player.send_request(type, args: args, auth: true, silent: true) + next res if !res + GlobalProperty.set_current_player(player) + return res + } + + # Attempt to perform request with: + # 1) Last valid player + # 2) Default player for requests + # 3) Any other player we have auth info for + request[player = GlobalProperty.get_current_player] + request[Player.find_by(steam_id: DATA_STEAM_ID)] if player.steam_id != DATA_STEAM_ID + attempts = 0 + players = Player.joins("INNER JOIN `steam_tickets` ON `steam_tickets`.`steam_id` = `players`.`steam_id`").order(date: :desc) + #players = Player.where.not(steam_id: nil).where(fast ? { active: true } : nil ).order(last_active: :desc) + players.each{ |player| + res = request[player] + return unless res.nil? # All subsequent players are unauthenticated, stop + redo if (attempts += 1) < RETRIES # Error, retry a few times + return # Too many errors, stop + } + + # Request failed + nil +rescue => e + lex(e, "Failed to GET #{type}") +end + +# Forward an arbitrary request to Metanet, return response's body if 200, nil else +def forward(req) + return nil if req.nil? + if req.query['steam_auth'].to_s.length < 2 * SteamTicket::MIN_TICKET_LENGTH + alert("Not forwarding to Metanet, invalid steam auth ticket: #{req.key}") + return + end + action_inc('http_forwards') + t = Time.now + + # Parse request elements + host = METANET_HOST + path = req.path + path.sub!(/\/[^\/]+/, '') if path[/\/(.+?)\//, 1] != 'prod' + body = req.body + + # Create request + uri = URI.parse("https://#{host}#{path}?#{req.query_string}") + case req.method + when 'GET' + new_req = Net::HTTP::Get.new(uri) + when 'POST' + new_req = Net::HTTP::Post.new(uri) + else + return nil + end + + # Add headers and body (clean default ones first) + new_req.to_hash.keys.each{ |h| new_req.delete(h) } + req.headers.each{ |k, v| new_req[k] = v } + new_req['host'] = host + new_req.body = body + + # Execute request + res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 5){ |http| + http.request(new_req) + } + dbg("Forwarded in %.3fs to Metanet: %s" % [Time.now - t, uri.to_s]) + res.code.to_i < 200 || res.code.to_i > 299 ? nil : res.body.to_s +rescue => e + lex(e, 'Failed to forward request to Metanet') + nil +end + +# Simple way to cache results and avoid quick-repeat of (HTTP/db/...) requests +# TODO: Make thread-safe +class Cache + + attr_reader :capacity, :entries, :adds, :taps, :name + + # For web content the key is the URL + Entry = Struct.new(:key, :size, :raw_size, :content, :duration, :expiry, :added, :accessed, :taps, :compressed) + + # Capacity in MB, duration in minutes + def initialize(capacity: 10, duration: 5, name: 'generic') + @capacity = 1024 ** 2 * capacity + @duration = 60 * duration + @entries = {} + @name = "#{name}-cache" + @adds = 0 + @taps = 0 + start + end + + # Check that an entry exists + def check(key) + @entries.key?(key) + end + + # Get the content of an entry + def get(key, decompress: true) + return if !check(key) + content = @entries[key].content + content = Zlib.inflate(content) if decompress && @entries[key].compressed + content + end + + # Mark an access to the entry + def tap(key) + return if !check(key) + @entries[key].accessed = Time.now + @entries[key].taps += 1 + @taps += 1 + end + + # Update the expiry of an entry + def touch(key) + return if !tap(key) + @entries[key].expiry = @entries[key].accessed + @entries[key].duration + end + + # Add or overwrite an entry + def add(key, content, duration = @duration / 60, compress: false) + remove(key) if check(key) + size = content.size + content = Zlib.deflate(content) if compress + return if !guarantee(content.size) + now = Time.now + @entries[key] = Entry.new(key, content.size, size, content, 60 * duration, now + 60 * duration, now, now, 0, compress) + @adds += 1 + end + + # Remove an entry from the cache + def remove(key) + @entries.delete(key) + end + + # Clear the entire cache + def clear + @entries = {} + end + + # Compute the total storage of the cache + def size + @entries.map{ |key, entry| entry.size }.sum + end + + # Compute the raw (uncompressed) storage of the cache + def raw_size + @entries.map{ |key, entry| entry.raw_size }.sum + end + + # Number of entries in the cache + def length + @entries.size + end + + private + + # Prune expired entries + def prune + @entries.each{ |key, entry| remove(key) if entry.expiry <= Time.now } + end + + # Start monitoring expired entries + def start + @thread = Thread.new do + Thread.current.name = @name.gsub(/\s+/, '_').downcase + loop do + sleep(60) + prune + end + end + end + + # Stop monitoring expired entries + def stop + @thread.kill + @thread = nil + end + + # Return the available space remaining in the cache + def available + @capacity - size + end + + # Free _at least_ "space" MB from the cache in order of closest to expiry + def free(space) + return if space <= 0 + return clear if space >= @capacity + @entries.sort_by{ |key, entry| entry.expiry } + .each{ |key, entry| + break if space <= 0 + space -= entry.size + remove(key) + } + end + + # Ensure there's enough space in the cache, if possible + def guarantee(space) + return false if space > @capacity + avail = available + return true if space <= avail + free(space - avail) + true + end +end + +# Helpers for HTML generation +module HTML extend self + def a(content, url, tab: true, title: '') + "#{content}" + end +end + +# Download a spreadsheet from Google Sheets +def get_sheet(id, api: false) + # Build complete URL + params = {} + if api + url = GOOGLE_API_SHEETS % [id] + args = { + 'fields' => 'properties,sheets(properties,data(rowData(values(formattedValue,hyperlink,note))))', + 'key' => $api_keys[:google][:sheets] + } + else + url = GOOGLE_URL_SHEETS % [id, 'export'] + args = { 'format' => 'xlsx' } + end + url << '?' + args.map{ |k, v| "#{k}=#{v}" }.join('&') + dbg("Downloading Google sheet from #{url}...") + + # Attempt to fetch, we often we redirects so we follow the jumps + begin + res = Net::HTTP.get_response(URI.parse(url)) + url = res['location'] + end while res.is_a?(Net::HTTPRedirection) + return err("Couldn't fetch Google sheet #{id}") if !res.is_a?(Net::HTTPSuccess) + dbg("Received #{res.body.size} bytes from Google sheet #{id}") + res.body +rescue => e + lex(e, "Failed to download Google sheet #{id}") +end + +# Generate a suitable link to GitHub. Raw links are direct downloads. +def github_link(path = '', user: GITHUB_USER, repo: GITHUB_REPO, branch: GITHUB_BRANCH, raw: true) + link = "https://github.com/#{user}/#{repo}" + link << "/#{raw ? 'raw' : 'blob'}/#{branch}/#{path}" if !path.empty? + link +end + +# <----------------------------------------------------------------------------> +# <------ (SEC4) SYSTEM OPERATIONS ------> +# <----------------------------------------------------------------------------> + +# Execute code block in another process +# +# Technical note: We disconnect from the db before forking and reconnect after, +# because otherwise the forked process inherits the same connection, and if +# it's broken there (e.g. by an unhandled exception), then it's also broken +# in the parent, thus losing connection until we restart. +def _fork + read, write = IO.pipe + ActiveRecord::Base.connection.disconnect! + + pid = fork do + read.close + result = yield + Marshal.dump(result, write) + rescue => e + lex(e, 'Error in forked process') + ensure + exit!(0) + end + + ActiveRecord::Base.connection.reconnect! + write.close + result = read.read + Process.wait(pid) + return nil if result.empty? + Marshal.load(result) +rescue => e + lex(e, 'Forking failed') + nil +end + +# Light wrapper to execute code block in thread +# Release db connection at the end if specified, also rescue errors +def _thread(release: false, name: 'thread') + Thread.new do + Thread.current.name = name.gsub(/\s+/, '_').downcase + yield + rescue => e + lex(e, 'Error in thread') + nil + ensure + release_connection if release + end +rescue => e + lex(e, 'Threading failed') + nil +end + +# Togglable threads are keyed by name so that we can dynamically work with them +# from other threads, even from Discord commands. +def toggle_thread_get(name) $threads[name] end +def toggle_thread_set(name, &block) $threads[name] = Thread.new(&block) end + +# Simple class to parallelize tasks with a pool of threads +class ThreadPool + SENTINEL = :stop + + attr_reader :queue + attr_accessor :count_done + + # Wrapper for each thread that stores state + class Thread + attr_reader :pool, :thread + attr_writer :status, :progress + + def initialize(pool) + @progress = 0 # 0-100 + @status = '' # Brief description of current activity + @pool = pool + @idle = false + @thread = ::Thread.new do + while task = @pool.queue.pop + break if task == SENTINEL + task.call(thread: self) + @pool.count_done += 1 + end + end + end + + # Idling is deliberate sleeping, as opposed to waiting on I/O, mutexes, etc, + # which also sets the thread to sleep. + def idle(t) + @idle = true + sleep(t) + @idle = false + end + + def status + return 'Idle' if @idle + return 'Done' if !@thread.alive? + @status + end + + def color + return ANSI.yellow if @idle + case @thread.status + when 'run', 'sleep' + ANSI.none + when 'aborting' + ANSI.yellow + when false + ANSI.green + else + ANSI.red + end + end + + def log(size = 30) + bar = progress_bar(@progress, 100, size: size, style: :filled, single: false) + "%s%s %s%s" % [color, bar, status, ANSI.none] + end + end + + def initialize(size) + @logged = false + @closed = false + @size = size + @queue = Queue.new + @mutex = Mutex.new + @threads = Array.new(size){ Thread.new(self) } + @count_total = 0 + @count_done = 0 + @time = Time.now + @msgs = [] + end + + def schedule(block) + return if @closed + @queue << block + @count_total += 1 + end + + def report(msg) + @msgs << msg + end + + def close + @size.times{ @queue << SENTINEL } unless @closed + @closed = true + end + + def block + @threads.each{ |th| th.thread.join } + end + + def alive + @threads.any?{ |th| th.thread.alive? } + end + + # Log current pool progress. Assumes nothing else is printing to the terminal + # at the moment, since it manages multiple terminal lines. + def log + # Back lines + str = '' + str << (ANSI.up + ANSI.erase_line) * (@size + 4) + "\r" if @logged + @logged = true + + # Log queued results + @msgs.each{ |msg| + str << msg << "\n" + @msgs.delete(msg) + } + + # Log header with pool status + running = @threads.count{ |th| !th.thread.stop? } + time = format_timespan(Time.now - @time, iso: true) + args = [running, @threads.size, @count_done, @count_total, time] + header = "│ Threads: %d / %d - Tasks: %d / %d - Time: %s │" % args + len = ANSI.unesc(header).length - 2 + str << ANSI.blue << '┌' << '─' * len << '┐' << "\n" + str << header << "\n" + str << '└┬' << '─' * (len - 1) << '┘' + ANSI.none << "\n" + + # Log status of each worker + @threads.each_with_index{ |th, i| + #char = i == 0 ? '└┬─' : i < @size - 1 ? ' ├─' : '┌┴─' + str << "%s ├─%s %-56s\n" % [ANSI.blue, ANSI.none, th.log] + } + str << ANSI.blue << ' └' << '─' * (len - 1) << '╴' << ANSI.none + + puts str + end +end + +# Execute a blocking shell command +# stream - Redirect STDOUT/STDERR to Ruby's terminal so we can see +# output - Return the output (STDOUT/STDERR/status) as an array of strings +# timeout - Seconds before sending SIGTERM. SIGKILL will be sent at twice this. +def shell(cmd, stream: LOG_SHELL, output: true, timeout: 0) + cmd += ' > /dev/null 2>&1' if !stream && !output + cmd.prepend("timeout -k #{2 * timeout} #{timeout} ") if timeout > 0 + dbg("Shell: #{cmd}") + output ? Open3.capture3(cmd) : system(cmd) +rescue => e + lex(e, "Failed to execute shell command: #{cmd}") +end + +# Execute a python script +def python(cmd, stream: LOG_SHELL, output: false, fast: false, timeout: 0) + interpreter = fast ? $tools[:python_fast] : $tools[:python] + return err("No #{fast ? 'fast ' : ''}Python interpreter found") if !interpreter + shell("#{interpreter} #{cmd}", stream: stream, output: output, timeout: timeout) +rescue => e + lex(e, "Failed to run Python script.") + nil +end + +# Represents a proc that will be queued in a worker thread but executed in the +# main thread. Once enqueued, this blocks the calling thread until the main +# one is done processing the command, and the result is filled. +class QueuedCmd + attr_reader :proc, :thread + attr_writer :result + + def initialize(proc, thread = Thread.current) + @proc = proc + @thread = thread + @result = nil + end + + def enqueue + $main_queue << self + sleep + @result + end +end + +# Return this process's memory usage in MB (Linux only) +# Spawning ps is slower but has broader support +def getmem(ps = false) + return `ps -p #{Process.pid} -o rss=`.to_i / 1024.0 if ps + line = nil + File.open("/proc/#{Process.pid}/status", 'rb') do |f| + line = f.find{ |l| l=~ /vmrss/i } + end + return 0 if !line + _, value, unit = line.downcase.split + converted = 1024 ** ['b', 'kb', 'mb', 'gb', 'tb'].index(unit) + value.to_f * converted / 1024 ** 2 +rescue + 0 +end + +# Return system's memory info in MB as a hash (Linux only) +def meminfo + File.read("/proc/meminfo").split("\n").map{ |f| f.split(':') } + .map{ |name, value| [name, value.to_i / 1024.0] }.to_h +rescue + {} +end + +# Execute a breakpoint silently +def quiet_breakpoint + old_hooks = Pry.config.hooks.dup + Pry.config.hooks = Pry::Hooks.new + binding.pry +ensure + Pry.config.hooks = old_hooks +end + +# <----------------------------------------------------------------------------> +# <------ (SEC5) BENCHMARKING ------> +# <----------------------------------------------------------------------------> + +# Wrapper to benchmark a piece of code +def bench(action, msg = nil, pad_str: 1, pad_num: 8) + now = Time.now + @t ||= now + @total ||= 0 + @step ||= 0 + case action + when :start + @step = 0 + @total = 0 + @t = now + when :step + int = now - @t + @step += 1 + @total += int + @t = now + dbg("Benchmark #{msg.nil? ? ("%02d" % @step) : ("%#{pad_str}s" % msg)}: #{"%#{pad_num}.3fms" % (int * 1000)} (Total: #{"%#{pad_num}.3fms" % (@total * 1000)}).") + end +end + +# Wrapper to do memory profiling for a piece of code +def profile(action) + case action + when :start + MemoryProfiler.start + when :stop + MemoryProfiler.stop.pretty_print( + to_file: File.join(DIR_LOGS, 'memory_profile.txt'), + scale_bytes: true, + detailed_report: true, + normalize_paths: true + ) + end +rescue => e + lex(e, 'Failed to do memory profiling') +end + +# <----------------------------------------------------------------------------> +# <------ (SEC6) STRING MANIPULATION ------> +# <----------------------------------------------------------------------------> + +# Convert a string to strict ASCII, replacing all invalid characters to underscores +# Optionally, also remove non-printable characters +def to_ascii(str, printable = true, extended: false) + charset = extended ? 'ISO-8859-1' : 'ASCII' + str = str.to_s + str = str.encode(charset, invalid: :replace, undef: :replace, replace: "_") + str = str.bytes.reject{ |b| b < 32 || b > 126 }.map(&:chr).join if printable + str +rescue + str.to_s +end + +def to_utf8(str) + str.bytes.reject{ |b| b < 32 || b == 127 }.map(&:chr).join.force_encoding('UTF-8').scrub('') +end + +def parse_str(str) + to_utf8(str.split("\x00")[0].to_s).strip +end + +def is_num(str) + return false if !str.is_a?(String) + str.strip == str[/\d+/i] +end + +def is_float(str) + Float(str) + true +rescue + false +end + +# Escape problematic chars (e.g. quotes or backslashes) +def escape(str) + str.dump[1..-2] +end + +# Inverse of the function above +def unescape(str) + "\"#{str}\"".undump +rescue + str +end + +# Make a string extra safe for Windows filenames (more restrictive than UNIX ones): +# - Convert to ASCII, replacing invalid and undefined chars by underscores +# - Remove non-printable characters +# - Replace reserved characters by underscores +# - Replace trailing periods and spaces by underscores +# TODO: Handle reserved names (e.g. CON, COM1, LPT1, etc) +def sanitize_filename(str, limit: FILENAME_LIMIT) + reserved = "\"*/:<>?\\|" + to_ascii(str).tr(reserved, '_').sub(/[\.\s]+$/) { |s| '_' * s.length }[0, limit] +end + +# Discord attachments have a stricter ruleset in order to be able to reference +# them via the attachment:// pattern. Namely, they must be alphanumeric with +# underscores, dashes and dots. The former 2 cannot be leading. +def sanitize_filename_embed(str) + str.gsub(/(^[_.])|[^A-Za-z0-9_\-.]/, '-') +end + +# Sanitize a string so that it is safe within an SQL LIKE statement +def sanitize_like(string, escape_character = "\\") + pattern = Regexp.union(escape_character, "%", "_") + string.gsub(pattern) { |x| [escape_character, x].join } +end + +def truncate_ellipsis(str, pad = DEFAULT_PADDING) + str if !str.is_a?(String) || !pad.is_a?(Integer) || pad < 0 + str.length <= pad ? str : (pad > 3 ? str[0...pad - 3] + '...' : str[0...pad]) +end + +def pad_truncate_ellipsis(str, pad = DEFAULT_PADDING, max_pad = MAX_PAD_GEN) + truncate_ellipsis(format_string(str, pad, max_pad, false)) +end + +# Automatic text wrapping +def wrap_text(str, width, word: true) + if word + str.gsub(/(.{1,#{width}})(?:\s+|$)/, "\\1\n") + else + last = str.length / width * width + str_new = str.dup + last.step(width, -width){ |i| str_new.insert(i, "\n") } + str_new.strip + end +end + +# Conditionally pluralize word +# If 'pad' we pad string to longest between singular and plural, for alignment +def cplural(word, n, pad = false) + sing = word + plur = word.pluralize + word = n == 1 ? sing : plur + pad = [sing, plur].map(&:length).max + "%-#{pad}s" % word +end + +# Run this function immediately after calling a userlevel function to clean +# some of the terms from the message +def clean_userlevel_message(event, words = []) + msg = parse_message(event) + msg.sub!(/(for|of)?\w*userlevel\w*/i, '') + words.each{ |word| msg.sub!(/\w*#{word}\w*/i, '') } + msg.squish! +end + +# Removes the first instance of a substring and removes extra spaces +def remove_word_first(msg, word) + msg.sub(/\w*#{word}\w*/i, '').squish +end + +# Strip off the @outte++ mention, if present +# IDs might have an exclamation mark +def remove_mentions!(msg) + msg.gsub!(/<@!?[0-9]*>\s*/, '') +end + +# Remove the command part of a special command +def remove_command(msg) + msg.sub(/^!\w+\s*/i, '').strip +end + +module ANSI extend self + # Sequence introducers + CSI = "\x1B[" + + # Format + NONE = 0 + BOLD = 1 + FAINT = 2 # No Discord support + ITALIC = 3 # No Discord support + UNDER = 4 + + # Resetters (no Discord support) + NOWEIGHT = 22 + NOITALIC = 23 + NOUNDER = 24 + + # Text colors + BLACK = 30 + RED = 31 + GREEN = 32 + YELLOW = 33 + BLUE = 34 + MAGENTA = 35 + CYAN = 36 + WHITE = 37 + + # Background colors + BLACK_BG = 40 + RED_BG = 41 + GREEN_BG = 42 + YELLOW_BG = 43 + BLUE_BG = 44 + MAGENTA_BG = 45 + CYAN_BG = 46 + WHITE_BG = 47 + + # Other formats (no Discord support) + FRAMED = 51 + ENCIRCLED = 52 + OVER = 53 + NOFRAMED = 54 + NOOVER = 55 + + # Bright text colors (no Discord support) + BRIGHT_BLACK = 90 + BRIGHT_RED = 91 + BRIGHT_GREEN = 92 + BRIGHT_YELLOW = 93 + BRIGHT_BLUE = 94 + BRIGHT_MAGENTA = 95 + BRIGHT_CYAN = 96 + BRIGHT_WHITE = 97 + + # Bright background colors (no Discord support) + BRIGHT_BLACK_BG = 100 + BRIGHT_RED_BG = 101 + BRIGHT_GREEN_BG = 102 + BRIGHT_YELLOW_BG = 103 + BRIGHT_BLUE_BG = 104 + BRIGHT_MAGENTA_BG = 105 + BRIGHT_CYAN_BG = 106 + BRIGHT_WHITE_BG = 107 + + # Terminal manipulation + def up(n = 1) "%s%dA" % [CSI, n] end # Moves cursor up n lines + def down(n = 1) "%s%dB" % [CSI, n] end # Moves cursor down n lines + def forw(n = 1) "%s%dC" % [CSI, n] end # Moves cursor forwards n cells + def back(n = 1) "%s%dB" % [CSI, n] end # Moves cursor backwards n cells + def erase_dis(n = 2) "%s%dJ" % [CSI, n] end # Erase display (0 = end, 1 = start, 2 = all) + def erase_lin(n = 2) "%s%dK" % [CSI, n] end # Erase line (0 = end, 1 = start, 2 = all) + def scroll_up(n = 1) "%s%dS" % [CSI, n] end # Scroll screen up n lines + def scroll_down(n = 1) "%s%dT" % [CSI, n] end # Scroll screen down n lines + + # Erasing shortcuts + def erase_display_end() erase_dis(0) end + def erase_display_start() erase_dis(1) end + def erase_display() erase_dis(2) end + def erase_line_end() erase_lin(0) end + def erase_line_start() erase_lin(1) end + def erase_line() erase_lin(2) end + + # SGR (Select Graphic Rendition) + def esc(nums = [0]) "%s%sm" % [CSI, nums.join(';')] end + def unesc(str) str.gsub(/\x1B\[[\d;]*m/, '') end + def get_esc(str) str[/\x1B\[[\d;]*m/] end + + def format(str, bold: false, faint: false, italic: false, underlined: false, fg: nil, bg: nil, close: true) + str = str.to_s + codes = [] + codes << BOLD if bold + codes << FAINT if faint + codes << ITALIC if italic + codes << UNDER if underlined + codes << fg if fg + codes << bg if bg + str.prepend(esc(codes)) unless codes.empty? + str << esc if close && !codes.empty? + str + end + + # Format code shortcuts + def none() esc() end + def bold() esc([BOLD]) end + def faint() esc([FAINT]) end + def italic() esc([ITALIC]) end + def under() esc([UNDER]) end + def normal() esc([NORMAL]) end + alias_method :clear, :none + alias_method :reset, :none + + # Basic color code shortcuts + def black() esc([BLACK]) end + def red() esc([RED]) end + def green() esc([GREEN]) end + def yellow() esc([YELLOW]) end + def blue() esc([BLUE]) end + def magenta() esc([MAGENTA]) end + def cyan() esc([CYAN]) end + def white() esc([WHITE]) end + + # Other color shortcuts + alias_method :good, :green + alias_method :bad, :red + alias_method :alert, :yellow + def bool(b) b ? good : bad end + def tri(n) n > 0 ? good : n < 0 ? bad : none end + +end + +# Format Markdown text +def mdtext(str, header: 0, url: nil, embed: true) + # Header stuff + str.prepend(' ') unless header == 0 + str.prepend('#' * header.abs) + str.prepend('-') if header < 0 + + # URL stuff + if url + url = '<' + url + '>' unless embed + str = "[#{str}](#{url})" + end + + str +end + +# MarkDown shotcuts +def mdurl(text, url, embed = true) mdtext(text, url: url, embed: embed) end +def mdhdr1(text) '# ' + text end +def mdhdr2(text) '## ' + text end +def mdhdr3(text) '### ' + text end +def mdsub(text) '-# ' + text end + +# Function to pad (and possibly truncate) a string according to different +# padding methods, determined by the constants defined at the start. +# It's a general function, but with a boolean we specify if we're formatting +# player names for leaderboards in particular, in which case, the maximum +# padding length is different. +def format_string(str, padding = DEFAULT_PADDING, max_pad = MAX_PADDING, leaderboards = true) + # Compute maximum padding length + max_pad = !max_pad.nil? ? max_pad : (leaderboards ? MAX_PADDING : MAX_PAD_GEN) + + # Compute actual padding length, based on the different constraints + pad = DEFAULT_PADDING + pad = padding if padding > 0 + pad = max_pad if max_pad > 0 && max_pad < padding + pad = SCORE_PADDING if SCORE_PADDING > 0 + + # Adjust padding if there are emojis or kanjis (characters with different widths) + # We basically estimate their widths and cut the string at the closest integer + # match to the desired padding + widths = str.chars.map{ |s| + s =~ Unicode::Emoji::REGEX ? WIDTH_EMOJI : (s =~ /\p{Han}|\p{Hiragana}|\p{Katakana}/i ? WIDTH_KANJI : 1) + } + total = 0 + totals = widths.map{ |w| total += w } + width = totals.min_by{ |t| (t - pad).abs } + chars = totals.index(width) + 1 + pad = pad > width ? chars + (pad - width).round : chars + + # Truncate and pad string + "%-#{"%d" % [pad]}s" % [TRUNCATE_NAME ? str.slice(0, pad) : str] +end + +# Converts an array of strings into a regex string that matches any of them +# with non-capturing groups (it can also take a string) +def regexize_words(words) + return '' if !words.is_a?(Array) && !words.is_a?(String) + words = [words] if words.is_a?(String) + words = words.reject{ |w| !w.is_a?(String) || w.empty? } + return '' if words.empty? + words = '(?:' + words.map{ |w| "(?:\\b#{Regexp.escape(w.strip)}\\b)" }.join('|') + ')' +rescue + '' +end + +# DISTANCE BETWEEN STRINGS +# * Find distance between two strings using the classic Damerau-Levenshtein +# * Returns nil if the threshold is surpassed +# * Read 'string_distance_list_mixed' for detailed docs +def string_distance(word1, word2, max: 3, th: 3) + d = DamerauLevenshtein.distance(word1, word2, 1, max) + (d - [word1.length, word2.length].min).abs < th ? nil : d +end + +# DISTANCE BETWEEN STRING AND PHRASE +# Same as before, but compares a word with a phrase, but comparing word by word +# and taking the MINIMUM (for single-word matches, which is common) +# Returns nil if the threshold is surpassed for EVERY word +def string_distance_chunked(word, phrase, max: 3, th: 3) + phrase.split(/\W|_/i).reject{ |chunk| chunk.strip.empty? }.map{ |chunk| + string_distance(word, chunk, max: max, th: th) + }.compact.min +end + +# DISTANCE BETWEEN WORD AND LIST +# (read 'string_distance_list_mixed' for docs) +def string_distance_list(word, list, max: 3, th: 3, chunked: false) + # Determine if IDs have been provided + ids = list[0].is_a?(Array) + # Sort and group by distance, rejecting invalids + list = list.each_with_index.map{ |n, i| + if chunked + [string_distance_chunked(word, ids ? n[1] : n, max: max, th: th), n] + else + [string_distance(word, ids ? n[1] : n, max: max, th: th), n] + end + } + .reject{ |d, n| d.nil? || d > max || (!th.nil? && (d - (ids ? n[1] : n).length).abs < th) } + .group_by{ |d, n| d } + .sort_by{ |g| g[0] } + .map{ |g| [g[0], g[1].map(&:last)] } + .to_h + # Complete the hash with the distance values that might not have appeared + # (this allows for more consistent use of the list, e.g., when zipping) + (0..max).each{ |i| list[i] = [] if !list.key?(i) } + list +end + +# DISTANCE-MATCH A STRING IN A LIST +# --- Description --- +# Sort list of strings based on a Damerau-Levenshtein-ish distance to a string. +# +# The list may be provided as: +# A list of strings +# A list of pairs, where the string is the second element +# This is used when there may be duplicate strings that we don't want to +# ditch, in which case the first element would be the ID that makes them +# unique. Obviously, this is done with level and player names in mind, that +# may have duplicates. +# +# The comparison between strings will be performed both normally and 'chunked', +# which splits the strings in the list in words. These resulting lists are then +# zipped (i.e. first distance 0, then chunked distance 0, the distance 1, etc.) +# --- Parameters --- +# word - String to match in the list +# list - List of strings / pairs to match in +# min - Minimum distance, all matches below this distance are keepies +# max - Maximum distance, all matches above this distance are ignored +# th - Threshold of maximum difference between the calculated distance +# and the string length to consider. The reason we do this is to +# ignore trivial results, eg, the distance between 'old' and 'new' +# is 3, not because the words are similar, but because they're only +# 3 characters long +# soft_limit - Limit of matches to show, assuming there aren't more keepies +# hard_limit - Limit of matches to show, even if there are more keepies +# Returns nil if the threshold is surpassed +def string_distance_list_mixed(word, list, min: 1, max: 3, max2: 2, th: 3, soft_limit: 10, hard_limit: 20) + matches1 = string_distance_list(word, list, max: max, th: th, chunked: false) + matches2 = string_distance_list(word, list, max: max2, th: th, chunked: true) + max = [max, max2].max + matches = (0..max).map{ |i| [i, ((matches1[i] || []) + (matches2[i] || [])).uniq(&:first)] }.to_h + keepies = matches.select{ |k, v| k <= min }.values.map(&:size).sum + to_take = [[keepies, soft_limit].max, hard_limit].min + matches.values.flatten(1).take(to_take) +end + +# This class represents each unit of a document, it's used primarily for building +# the documentation that's shown with the "help" command. Each Doc can have sections, +# which are Docs themselves, thus building the entire documentation in book fashion. +class Doc + attr_accessor :parent + attr_writer :index, :level + attr_reader :sections, :title + + def initialize(title, content = '') + @title = title + @content = content + @sections = [] + @index = 0 + @parent = nil + @level = 1 + end + + def <<(doc) + @sections << doc + doc.index = @sections.length + doc.parent = self + doc.level = @level + 1 + self + end + + def write(content) + @content << content + end + + def leaf? + @sections.empty? + end + + def root? + @index == 0 + end + + def seq + return '' if root? + (@parent.root? ? '' : @parent.seq + '.') + @index.to_s + end + + def breadcrumbs + (@parent ? @parent.breadcrumbs + ' > ' : '') + @title + end + + def toc + return '' if @sections.empty? + toc = ANSI.bold + ANSI.under + ANSI.blue + "Table of Contents\n" + ANSI.clear + ANSI.blue + toc << @sections.map.with_index{ |s, i| " %d. %s" % [i + 1, s.title] }.join("\n") + ANSI.clear + format_block(toc) + end + + def to_s + "## 📄 [#{seq}] #{breadcrumbs}\n#{@content}\n#{toc}" + end + + def render(*sects) + node = self + sects.each do |idx| + break if node.leaf? + node = node.sections[idx] + end + node.to_s + end +end + +# <----------------------------------------------------------------------------> +# <------ (SEC7) BINARY MANIPULATION ------> +# <----------------------------------------------------------------------------> + +# Convert an integer into a little endian binary string of 'size' bytes and back +# TODO: Substitute most/all uses of this with Ruby's native pack/unpack functions +def _pack_raw(n, size) + n.to_s(16).rjust(2 * size, "0").scan(/../).reverse.map{ |b| + [b].pack('H*')[0] + }.join.force_encoding("ascii-8bit") +end + +def _pack(n, arg) + if arg.is_a?(String) + [n].pack(arg) + else + _pack_raw(n, arg.to_i) + end +rescue + _pack_raw(n, arg.to_i) +end + +def _unpack(bytes, fmt = nil) + if bytes.is_a?(Array) then bytes = bytes.join end + if !bytes.is_a?(String) then bytes.to_s end + i = bytes.unpack(fmt)[0] if !fmt.nil? + i ||= bytes.unpack('H*')[0].scan(/../).reverse.join.to_i(16) +rescue + if bytes.is_a?(Array) then bytes = bytes.join end + bytes.unpack('H*')[0].scan(/../).reverse.join.to_i(16) +end + +# <----------------------------------------------------------------------------> +# <------ (SEC8) FILE MANIPULATION ------> +# <----------------------------------------------------------------------------> + +FMT_SIZES = { + 'a' => 1, 'A' => 1, 'Z' => 1, 'x' => 1, 'X' => -1, + 'c' => 1, 'C' => 1, + 's' => 2, 'S' => 2, 'n' => 2, 'v' => 2, + 'l' => 4, 'L' => 4, 'i' => 4, 'I' => 4, 'N' => 4, 'V' => 4, + 'f' => 4, 'F' => 4, 'e' => 4, 'g' => 4, + 'q' => 8, 'Q' => 8, 'j' => 8, 'J' => 8, + 'd' => 8, 'D' => 8, 'E' => 8, 'G' => 8 +} + +# Helper for reading files. Raises if not n bytes left to read. +def assert_left(f, n) + raise "Not enough bytes left (#{f.size} < #{f.pos + n})" if f.size - f.pos < n +end + +# Computes the size in bytes of a binary string given the packing format string. +def fmtsz(fmt) + fmt.tr('<>!_', '').scan(/([A-Za-z])(\d+)?/) + .inject(0){ |sum, pat| sum + FMT_SIZES[pat[0]] * (pat[1] || 1).to_i } +end + +# Unpacks a series of bytes from an open IO object. Only works for unpacking numeric +# types (e.g. integers or floats) and strings. +def ioparse(f, fmt) + sz = fmtsz(fmt) + assert_left(f, sz) + f.read(sz).unpack(fmt) +end + +# Create a ZIP file. Provided data should be a Hash with the filenames +# as keys and the file contents as values. +def zip(data) + Zip::OutputStream.write_buffer{ |zip| + data.each{ |name, content| + zip.put_next_entry(name) + zip.write(content) + } + }.string +end + +def unzip(data) + res = {} + Zip::File.open_buffer(data){ |zip| + zip.each{ |entry| + res[entry.name] = entry.get_input_stream.read + } + } + res +end + +# <----------------------------------------------------------------------------> +# <------ (SEC9) DISCORD RELATED ------> +# <----------------------------------------------------------------------------> + +# Discord API version currently in use by Discordrb +def discord_api_version + Discordrb::API.api_base[/v(\d+)/, 1].to_i +end + +# Find the botmaster's Discord user +def botmaster + $bot.servers.each{ |id, server| + member = server.member(BOTMASTER_ID) + return member if !member.nil? + } + err("Couldn't find botmaster") + nil +rescue => e + lex(e, "Error finding botmaster") + nil +end + +def is_botmaster?(event) + event.user.id == BOTMASTER_ID +end + +# Get a specific component from a message, by type and ID +def get_component(msg, type: nil, id: nil) + components = msg.components.map{ |row| row.components }.flatten + components.select!{ |c| + case type + when :button + c.is_a?(Discordrb::Components::Button) + when :select_menu + c.is_a?(Discordrb::Components::SelectMenu) + else + false + end + } if type + components.select!{ |c| c.custom_id == id } if id + components.first +rescue => e + lex(e, 'Error getting component') + nil +end + +# Find Discord server the bot is in, by ID or name +def find_server(id: nil, name: nil) + if id + $bot.servers[id] + elsif name + $bot.servers.values.find{ |s| s.name.downcase.include?(name.downcase) } + else + nil + end +rescue + nil +end + +def find_channel_in_server(id: nil, name: nil, server: nil) + return nil if server.nil? + if id + server.channels.find{ |c| c.id == id } + elsif name + server.channels.find{ |c| c.name.downcase.include?(name.downcase) } + else + nil + end +rescue + nil +end + +# Find Discord channel by ID or name, server optional +def find_channel(id: nil, name: nil, server_id: nil, server_name: nil) + server = find_server(id: server_id, name: server_name) + if server + find_channel_in_server(id: id, name: name, server: server) + else + $bot.servers.each{ |_, s| + channel = find_channel_in_server(id: id, name: name, server: s) + return channel if !channel.nil? + } + nil + end +rescue + nil +end + +# Find emoji by ID or name +def find_emoji(key, server = nil) + server = server || $bot.servers[SERVER_ID] || $bot.servers.first.last + return if server.nil? + if key.is_a?(Integer) + server.emojis[key] + elsif key.is_a?(String) + res = server.emojis.find{ |id, e| e.name == key } + res = server.emojis.find{ |id, e| e.name.downcase == key.downcase } unless res + res = server.emojis.find{ |id, e| e.name.downcase.include?(key.downcase) } unless res + res ? res[1] : nil + else + nil + end +rescue + nil +end + +# Find an app emoji. Obsolete now that discordrb supports them. +def app_emoji(name) + return if !APP_EMOJIS.key?(name) + id = APP_EMOJIS[name][TEST ? :test : :prod] + return if !id + "<:#{name}:#{id}>" +end + +# Fetch all available app emojis using discordrb, store them in a global var +# keyed by the name so we only need to do this once on startup +def fetch_app_emojis + $emojis = $bot.application_emojis.index_by(&:name) + dbg("Fetched #{$emojis.size} application emojis") + $emojis +end + +# Find user by name and tag in a given server +def find_users_in_server(name: nil, tag: nil, server: nil) + return [] if !server || !name + server.users.select{ |u| + u.name.downcase == name.downcase && (!tag.nil? ? u.tag == tag : true) + } +rescue + [] +end + +def find_users(name: nil, tag: nil) + $bot.servers.map{ |_, server| + find_users_in_server(name: name, tag: tag, server: server) + }.flatten +rescue + [] +end + +# React to a Discord msg (by ID) with an emoji (by Unicode or name) +def react(channel, msg_id, emoji) + channel = find_channel(name: channel) rescue nil + perror('Channel not found') if channel.nil? + msg = channel.message(msg_id.to_i) rescue nil + perror('Message not found') if msg.nil? + emoji = find_emoji(emoji, channel.server) if emoji.ascii_only? rescue nil + perror('Emoji not found') if emoji.nil? + msg.react(emoji) +end + +def unreact(channel, msg_id, emoji = nil) + channel = find_channel(name: channel) rescue nil + perror('Channel not found') if channel.nil? + msg = channel.message(msg_id.to_i) rescue nil + perror('Message not found') if msg.nil? + if emoji.nil? + msg.my_reactions.each{ |r| + emoji = r.name.ascii_only? ? find_emoji(r.name, channel.server) : r.name + msg.delete_own_reaction(emoji) + } + else + emoji = find_emoji(emoji, channel.server) if emoji.ascii_only? rescue nil + perror('Emoji not found') if emoji.nil? + msg.delete_own_reaction(emoji) + end +end + +# Pings a role by name (returns ping string) +def ping(rname) + server = TEST ? $bot.servers.values.first : $bot.servers[SERVER_ID] + if server.nil? + log("Server not found") + return "" + end + + role = server.roles.select{ |r| r.name == rname }.first + if role != nil + if role.mentionable + return role.mention + else + log("Role #{rname} in server #{server.name} not mentionable") + return "" + end + else + log("Role #{rname} not found in server #{server.name}") + return "" + end +rescue => e + lex(e, "Failed to ping role #{rname}") + "" +end + +# Return the string that produces a clickable channel mention in Discord +def mention_channel(name: nil, id: nil, server_name: nil, server_id: nil) + channel = find_channel(id: id, name: name, server_id: server_id, server_name: server_name) + return '' if channel.nil? + channel.mention +rescue => e + lex(e, 'Failed to mention Discord channel') + '' +end + +# Format a string as a one-line block, which removes all special Markdown +# formatting and just shows the raw text. +# Current, this is done by enclosing the text within backticks. +def verbatim(str) + str = str.to_s.tr('`', '') + str = ' ' if str.empty? + "`#{str}`" +end + +# Format a string as a multi-line block. +# Currently, this is done by enclosing the text within triple backticks. +def format_block(str) + str = str.to_s.gsub('```', '') + str = ' ' if str.empty? + "```ansi\n#{str}```" +end + +# Format a string as a spoiler, by enclosing it within double vertical bars. +def spoiler(str) + str = str.to_s + return "|| ||" if str.empty? + "||#{str.gsub('||', '')}||" +end + +# Class to hold a temporary message sent to Discord, whose content may be updated +# and eventually deleted. Intended for easy manipulation of things like progress +# indicators for long processes. +class TmpMsg + + @@msgs = {} + + def self.fetch(event) + @@msgs[event] + end + + def self.update(event, content, temp: true) + if !@@msgs.key?(event) + @@msgs[event] = new(event, content, temp: temp) + else + @@msgs[event].edit(content, temp: temp) + end + end + + def self.delete(event) + return false if !@@msgs.key?(event) + @@msgs[event].delete + @@msgs.delete(event) + true + end + + def initialize(event, content, temp: true) + @event = event + @content = content + @temp = temp + @mutex = Mutex.new + @msg = nil + + @@msgs[event] = self + send + end + + def send + _thread do + @mutex.synchronize do + content = @content + @content = nil + @msg = send_message(@event, content: content) rescue nil + end + end + self + end + + def edit(content, temp: true) + @content = content + @temp = false if !temp + _thread do + @mutex.synchronize do + next if !@content || !@msg + content = @content + @content = nil + @msg.edit(content) rescue nil + end + end + self + end + + def delete + @@msgs.delete(@event) + if @temp + @mutex.synchronize do + @msg.delete rescue nil + end + end + @event = nil + end + + def ready? + !!@content + end + + def sent? + !!@msg + end +end + +# Set the avatar to an image given the name +def change_avatar(avatar) + File::open(File.join(PATH_AVATARS, avatar)) do |f| + $bot.profile.avatar = f + end +rescue + perror("Too many changes! Wait and try again.") +end + +# Return the channel type as per Discord's API +# 0 Text channel +# 1 DM +# 2 Voice +# 3 Group +# 4 Category +# 5 News / announcements +# 6 Store +# 10 News thread +# 11 Public thread +# 12 Private thread +# 13 Stage voice +# 14 Directory (channel in server hub) +# 15 Forum (thread container) +# 16 Media channel +def channel_type(type) + Discordrb::Channel::TYPES[type.to_s.downcase.to_sym] +end + +# Return a default mappack based on the user and channel +def default_mappack(user, channel) + # User-specific global default + return user.mappack if user && user.mappack_default_always && user.mappack + + # Channel-specific default + pack = MappackChannel.find_by(id: channel.id).mappack rescue nil + return pack if pack + + # User-specific channel default + return nil if !user || !channel || !user.mappack + return user.mappack if user.mappack_default_dms && channel.type == channel_type(:dm) + + nil +rescue + nil +end + +# Download the files attached to a Discord message +# TODO: Use this for update_ntrace +def fetch_attachments(event, filter: nil, limit_size: 5 * 1024 ** 2, limit_files: 10, log: true) + # Fetch attachment list, filter by name pattern, and truncate + files = event.message.attachments + files.select!{ |f| f.filename =~ filter } if filter + if files.size > limit_files + event << "-# **Warning**: Only reading the first #{limit_files} files (too many files)" if log + files.pop(files.size - limit_files) + end + + files.map{ |f| + # Skip files over the size limit + if f.size > limit_size + event << "-# **Warning**: File #{f.filename} skipped (too large)" if log + next + end + + # Skip corrupt files + body = Net::HTTP.get(URI(f.url)) rescue nil + if !body || body.size != f.size + event << "-# **Warning**: File #{f.filename} skipped (corrupt data received)" if log + next + end + + [f.filename, body] + }.compact.to_h +end + +# <----------------------------------------------------------------------------> +# <------ (SEC10) N++ SPECIFIC ------> +# <----------------------------------------------------------------------------> + +# Verifies if an arbitrary floating point can be a valid score +def verify_score(score) + decimal = (score * 60) % 1 + [decimal, 1 - decimal].min < 0.03 +end + +# Sometimes we need to make sure there's exactly one valid type +def ensure_type(type, mappack: false) + base = mappack ? MappackLevel : Level + type.nil? ? base : (type.is_a?(Array) ? (type.include?(base) ? base : type.flatten.first) : type) +end + +# Converts any type input to an array of type classes +# Also converts types to mappack ones if necessary +def normalize_type(type, empty: false, mappack: false) + type = DEFAULT_TYPES.map(&:constantize) if type.nil? + type = [type] if !type.is_a?(Array) + type = DEFAULT_TYPES.map(&:constantize) if !empty && type.empty? + type.map{ |t| + t = t.constantize if t.is_a?(String) + mappack ? t.mappack : t.vanilla + } +end + +# Normalize how highscoreable types are handled. +# A good example: +# [Level, Episode] +# Bad examples: +# nil (transforms to [Level, Episode]) +# Level (transforms to [Level]) +# 'single' means we return a single type instead +def fix_type(type, single = false) + if single + ensure_type(type) + else + type.nil? ? DEFAULT_TYPES.map(&:constantize) : (!type.is_a?(Array) ? [type] : type) + end +end + +# find the optimal score / amount of whatever rankings or stat +def find_max_type(rank, type, tabs, mappack = nil, board = 'hs', dev = false, frac = false) + # Filter scores by type and tabs + type = Level if rank == :gm + type = mappack || rank == :gp ? type.mappack : type.vanilla + query = mappack || rank == :gp ? type.where(mappack: mappack) : type + query = query.where(tab: tabs) if !tabs.empty? + query = query.where.not("dev_#{board}" => nil) if mappack && dev + + # Distinguish ranking type + case rank + when :points + query.count * 20 + when :avg_points + 20 + when :avg_rank + 0 + when :maxable + Highscoreable.ties(type, tabs, nil, false, true, mappack, board).size + when :maxed + Highscoreable.ties(type, tabs, nil, true, true, mappack, board).size + when :gp + query.sum(:gold) + when :gm + klass = mappack.nil? ? Score : MappackScore.where(mappack: mappack) + query = klass.where(highscoreable_type: type) + query = query.where(tab: tabs) if !tabs.empty? + # query.group(:highscoreable_id).minimum(:gold).values.sum + MappackScore.from( + query.group(:highscoreable_id).select('MIN(gold) AS gold'), + :t + ).sum('t.gold') + when :clean + 0.0 + when :score + total, count = Scorish.total_scores(Level, tabs, type.include?(Levelish), !type.include?(Storyish), mappack, board, frac) + size = TYPES[type.vanilla.to_s][:size] + total -= count * (size - 1) * 90.0 / size if board == 'hs' + mappack && board == 'sr' && !frac ? total.to_i : total.to_f + else + query.count + end +end + +# Finds the maximum value a player can reach in a certain ranking +# If 'empty' we allow no types, otherwise default to Level and Episode +def find_max(rank, types, tabs, empty = false, mappack = nil, board = 'hs', dev = false, frac = false) + # Normalize params + types = normalize_type(types, empty: empty) + + # Compute type-wise maxes, and add + maxes = [types].flatten.map{ |t| find_max_type(rank, t, tabs, mappack, board, dev, frac) } + [:avg_points, :avg_rank].include?(rank) ? maxes.first : maxes.sum +end + +# Finds the minimum number of scores required to appear in a certain +# average rank/point rankings +# If 'empty' we allow no types, otherwise default to Level and Episode +# If 'a' and 'b', we weight the min scores by the range size +# If 'star' then we're dealing with only * scores, and we should again be +# more gentle +def min_scores(type, tabs, empty = false, a = 0, b = 20, star = false, mappack = nil) + # We ignore mappack mins for now + return 0 if !mappack.nil? + + # Normalize types + types = normalize_type(type, empty: empty) + + # Compute mins per type, and add + mins = types.map{ |t| + if tabs.empty? + type_min = TABS[t.to_s].sum{ |k, v| v[2] } + else + type_min = tabs.map{ |tab| TABS[t.to_s][tab][2] if TABS[t.to_s].key?(tab) }.compact.sum + end + [type_min, TYPES[t.to_s][:min_scores]].min + }.sum + + # Compute final count + c = star ? 1 : a && b ? b - a : 20 + ([mins, MAXMIN_SCORES].min * c / 20.0).to_i +end + +# round float to nearest frame +def round_score(score) + score.is_a?(Integer) ? score : (score * 60).round / 60.0 +end + +# Calculate episode splits based on the 5 level scores +def splits_from_scores(scores, start: 90.0, factor: 1, offset: 90.0, frac: false) + acc = start + splits = scores.map{ |s| acc += (s / factor - offset) } + splits = splits.map{ |s| round_score(s) } unless frac + splits +end + +# TODO: Generalize with scale param to sr mode and userlevels/mappacks +def scores_from_splits(splits, offset: 90.0, frac: false) + scores = splits.each_with_index.map{ |s, i| i == 0 ? s : s - splits[i - 1] + offset } + scores = scores.map{ |s| round_score(s) } unless frac + scores +end + +# Convert N v1.4 coordinates to N++ coordinates. Since map sizes differ, we +# may want to offset the map data by an integer number of tiles (normally, +# ox = 6, oy = 0, to center it). If the object is out of bounds but still within +# valid range, we set a boolean. If it doesn't even fit in a byte, +# we set another boolean. +def nv14_coord(x, y, ox, oy) + xf, yf = 4 * (x / Map::NV14_UNITS + ox), 4 * (y / Map::NV14_UNITS + oy) + zsnap = true if !is_int(xf) || !is_int(yf) + x, y = xf.round, yf.round + oob = true if !x.between?(4, 4 * (Map::COLUMNS + 1)) || !y.between?(4, 4 * (Map::ROWS + 1)) + skip = true if !x.between?(0, 0xFF) || !y.between?(0, 0xFF) + [x, y, xf, yf, zsnap, oob, skip] +end + +# Computes the name of a highscoreable based on the ID and type, e.g.: +# Type = 0, ID = 2637 ---> SU-C-09-02 +# The complexity of this function lies in: +# 1) The type itself (Level, Episode, Story) changes the computation. +# 2) Only some tabs have X row. +# 3) Only some tabs are secret. +# 4) Lastly, and perhaps most importantly, some tabs in Coop and Race are +# actually split in multiple files, with the corresponding bits of +# X row staggered at the end of each one. +# NOTE: Some invalid IDs will return valid names rather than nil, e.g., if +# type is Story and ID = 120, it will return "!-00", a non-existing story. +# This is a consequence of the algorithm, but it's harmless if only correct +# IDs are inputted. +def compute_name(id, type) + return nil if ![0, 1, 2].include?(type) + f = 5 ** type # scaling factor + + # Fetch corresponding tab + tab = TABS_NEW.find{ |_, t| (t[:start]...t[:start] + t[:size]).include?(id * f) } + return nil if tab.nil? + tab = tab[1] + + # Get stories out of the way + return "#{tab[:code]}-#{"%02d" % (id - tab[:start] / 25)}" if type == TYPE_STORY + + # Get super secret levels out of the way + if type == TYPE_LEVEL && tab[:mode] == MODE_SOLO && tab[:tab] == TAB_DLC + code = id % 50 < 25 ? '?!' : '!?' + letters = ('A'..'Z').to_a.reject{ |letter| letter == 'N' } + return code + '-' + letters[id % 25] + end + + # Compute offset in tab and file + tab_offset = id - tab[:start] / f + file_offset = tab_offset + file_count = tab[:files].values[0] / f + tab[:files].values.inject(0){ |sum, n| + if sum <= tab_offset + file_offset = tab_offset - sum + file_count = n / f + end + sum + n / f + } + + # If it's a secret level tab, its numbering is episode-like + if type == 0 && tab[:secret] + type = 1 + f = 5 + end + + # Compute episode and column offset in file + rows = tab[:x] ? 6 : 5 + file_eps = file_count * f / 5 + file_cols = file_eps / rows + episode_offset = file_offset * f / 5 + if tab[:x] && episode_offset >= 5 * file_eps / 6 + letter = 'X' + column_offset = episode_offset % file_cols + else + letter = ('A'..'E').to_a[episode_offset % 5] + column_offset = episode_offset / 5 + end + + # Compute column (and level number) + prev_count = tab_offset - file_offset + prev_eps = prev_count * f / 5 + prev_cols = prev_eps / rows + col = column_offset + prev_cols + lvl = tab_offset % 5 + + # Return name + case type + when TYPE_LEVEL + "#{tab[:code]}-#{letter}-#{"%02d" % col}-#{"%02d" % lvl}" + when TYPE_EPISODE + "#{tab[:code]}-#{letter}-#{"%02d" % col}" + end +end + +# Stores all the information related to an NSim simulation, including the input +# map data and demo data, the resulting position and collision information, etc. +# It may contain multiple simulations for the same level, since they're all traced +# together. +class NSim + + attr_reader :count, :success, :correct, :valid, :valid_flags, :complexity, :splits, :scores, :stats + attr_accessor :ppc + Collision = Struct.new(:id, :index, :state) + + # One-shot usage of the simulator, shortcut to avoid creation and cleanup of NSim objects + def self.run(map_data, demo_data, basic_sim: true, basic_render: true, silent: false, &block) + return if !block_given? + nsim = new(map_data, demo_data) + nsim.run(basic_sim: basic_sim, basic_render: basic_render, silent: silent) + ret = yield(nsim) + nsim.destroy + ret + end + + def initialize(map_data, demo_data) + @splits_mode = map_data.is_a?(Array) + @map_data = map_data + @demo_data = @splits_mode ? demo_data : demo_data.take(MAX_TRACES) + @demos = @splits_mode ? Demo.decode(@demo_data) : @demo_data.map{ |d| Demo.decode(d) } + @count = @splits_mode ? 1 : @demo_data.size + @success = false # Was nsim executed successfully? + @correct = false # Was nsim output parsed correctly? + @valid = false # Was nsim result a valid run? + @output = '' + @valid_flags = [] + @scores = [] + @splits = [] + @raw_coords = 40.times.map{ |id| [id, {}] }.to_h + @raw_chunks = 40.times.map{ |id| [id, {}] }.to_h + @raw_collisions = {} + @ppc = 0 # Determines the default scaling factor for coordinates + @complexity = 0 # Total coordinates + @stats = {} + end + + # Export input files for nsim + private def export + if @splits_mode + File.binwrite(NTRACE_INPUTS_E, @demo_data) + @map_data.each_with_index{ |map, i| File.binwrite(NTRACE_MAP_DATA_E % i, map) } + else + @demo_data.each_with_index{ |demo, i| File.binwrite(NTRACE_INPUTS % i, demo) } + File.binwrite(NTRACE_MAP_DATA, @map_data) + end + end + + # Execute simulation + # TODO: Store all scores in trace mode into @scores + private def execute(basic_sim: true, basic_render: true, silent: false) + t = Time.now + path = PATH_NTRACE + (basic_sim ? ' --basic-sim' : '') + (basic_render ? '' : ' --full-export') + stdout, stderr, status = python(path, output: true, fast: false) + @output = [stdout, stderr].join("\n\n") + dbg("NSim simulation time: %.3fs" % [Time.now - t]) unless silent + @success = status.success? && File.file?(@splits_mode ? NTRACE_OUTPUT_E : NTRACE_OUTPUT) + end + + # Remove all temp files + private def clean + if @splits_mode + FileUtils.rm_f([NTRACE_INPUTS_E, *Dir.glob(NTRACE_MAP_DATA_E % '*')]) + else + FileUtils.rm_f([NTRACE_MAP_DATA, *Dir.glob(NTRACE_INPUTS % '*')]) + end + FileUtils.rm_f([@splits_mode ? NTRACE_OUTPUT_E : NTRACE_OUTPUT]) + end + + # Parse nsim's output file in trace mode and read coordinates and collisions + private def parse_splits(f) + body = f.read + @valid_flags = body.scan(/True|False/).map{ |b| b == 'True' } + @splits = body.split(/True|False/)[1..-1].map{ |d| + round_score(d.strip.to_i.to_f / 60.0) + } + @scores = scores_from_splits(splits, offset: 90.0) + end + + # Parse nsim's output in splits mode and read level splits and valid flags + # TODO: Should we deduplicate collisions, or handle it later? + private def parse_trace(f) + # Run count and valid flags + n, = ioparse(f, 'C') + @valid_flags = ioparse(f, 'C' * n).map{ |b| b > 0 } + + n.times do |i| + # Entity coordinate section + entity_count, = ioparse(f, 'S<') + entity_count.times do + id, index, chunk_count = ioparse(f, 'CS e + lex(e, 'Failed to parse NSim output') + @correct = false + end + dbg("NSim read size: %.3fKiB" % [f.pos / 1024.0]) unless silent + dbg("NSim parse time: %.3fms" % [1000.0 * (Time.now - t)]) unless silent + ensure + f&.close + end + + # Parse the stats printed to the terminal after execution + private def parse_stats + start = @output.strip.rindex("\n").to_i + @stats = JSON.parse(@output[start..]) rescue {} + end + + # Print debug information + def dbg(event) + if @output.length < DISCORD_CHAR_LIMIT - 100 + output = @output.strip.empty? ? 'None' : "\n" + format_block(@output) + return "Debug info (terminal output): " + output + end + + _thread do + sleep(0.5) + event.send_file( + tmp_file(@output, 'nsim_output.txt', binary: false), + caption: "Debug info (terminal output):" + ) + end + + '' + end + + # Check if nsim was executed successfully, its output parsed correctly, and + # its result is a valid run + private def validate + @valid = @success && @correct && @valid_flags.all? + end + + # Run simulation and parse result inside the NSim mutex + def run(basic_sim: true, basic_render: true, silent: false) + $mutex[:nsim].synchronize do + export + execute(basic_sim: basic_sim, basic_render: basic_render, silent: silent) + parse(silent: silent) if @success + rescue => e + lex(e, 'Error running NSim') + ensure + clean + end + parse_stats if @success + compute_complexity(silent: silent) if @correct + validate + end + + # Ensure simulation correctness or halt otherwise + # @par debug: Include debug information in error msg + # @par strict: Force simulation to be valid + def check(event, debug: false, strict: true) + return true if @valid || !strict && @correct + if !@success + str = 'Simulation failed' + elsif !@correct + str = 'Simulation results are corrupt' + else + str = 'Simulation isn\'t valid' + end + str << ', contact the botmaster for details.' + if debug + debug_info = dbg(event) + str << ' ' + debug_info unless debug_info.empty? + end + perror(str) + false + end + + # Free references to allocated data so that it's hopefully garbage collected + def destroy + @map_data.each(&:clear) if @splits_mode + @map_data.map!{ nil } if @splits_mode + @map_data.clear + @map_data = nil + + @demo_data.each(&:clear) if !@splits_mode + @demo_data.map!{ nil } if !@splits_mode + @demo_data.clear + @demo_data = nil + + @demos.each(&:clear) + @demos.map!{ nil } + @demos.clear + @demos = nil + + return if @splits_mode + + @raw_collisions.each{ |frame, cols| cols.clear } + @raw_collisions.keys.each{ |frame| @raw_collisions[frame] = nil } + @raw_collisions.clear + @raw_collisions = nil + + clear_coords(true) + @raw_coords.keys.each{ |id| @raw_coords[id] = nil } + @raw_coords.clear + @raw_coords = nil + @raw_chunks.keys.each{ |id| @raw_chunks[id] = nil } + @raw_chunks.clear + @raw_chunks = nil + + @output.clear + @output = nil + end + + # Free coordinate data (we may just want to do this to disable full anims on command) + def clear_coords(clear_ninja = false) + @raw_coords.each{ |id, hash| + next if id == Map::ID_NINJA && !clear_ninja + hash.each{ |index, coords| coords.clear } + hash.keys.each{ |index| hash[index] = nil } + hash.clear + } + + @raw_chunks.each{ |id, hash| + next if id == Map::ID_NINJA && !clear_ninja + hash.each{ |index, chunks| + chunks.each(&:clear) + chunks.clear + } + hash.keys.each{ |index| hash[index] = nil } + hash.clear + } + end + + # Length of the simulation, in frames + def length(index = nil) + if @splits_mode + index ? @demos[index].size : @demos.map(&:size).sum + else + index ? @raw_chunks[0][index][1].sum : @raw_chunks[0].map{ |index, chunks| chunks[1].sum }.max + end + end + + # Run score taken from nclone's terminal output + def score(index = 0) + return if !@valid_flags[index] + round_score(@stats['scores'][index]) rescue nil + end + + # Run fractional score taken from nclone's terminal output + # Note: (1 - fraction) is how much of the frame has elapsed, thus it must be + # in [0, 1). If it were 1, collision wouldn't have happened on this frame. + def frac(index = 0) + return if !@valid_flags[index] + 1 - @stats['fractions'][index] rescue nil + end + + # Run is finished for this ninja on this frame + def finished?(index, frame, trace: false) + length(index) < frame + 1 + (trace ? 1 : 0) + end + + # Run just finished on the given frame range (of width "step") + def just_finished?(index, frame, step, trace: false) + length(index).between?( + frame + 1 + (trace ? 1 : 0), + frame + step + (trace ? 1 : 0) + ) + end + + # Return coordinates of an entity for the given frame + # ppc contains the scale (in pixels per coordinate) if the coordinates need + # to be scaled for drawing + def coords(id, index, frame, ppc: @ppc) + pos = fetch_coords(id, index, frame) + return nil if !pos + rescale(pos, ppc) + end + + # Return coordinates of a ninja for the given frame + def ninja(index, frame, ppc: @ppc) + coords(0, index, frame, ppc: ppc) + end + + # Return inputs of a ninja during the given frame + def inputs(index, frame) + @demos&.[](index)&.[](frame) + end + + # Return array of collisions for the given frame + def collisions(frame) + return [] if !@raw_collisions[frame] + @raw_collisions[frame].scan(/..../m).map{ |c| + id, index, state = c.unpack('CS max_frame + chunk_index = chunks[0].rindex{ |f| f <= frame } + chunk_frame = chunks[0][chunk_index] + chunk_length = chunks[1][chunk_index] + d = [frame - chunk_frame, chunk_length - 1].min + f = chunks[1].take(chunk_index).sum + d + poslog[4 * f, 4].unpack('s<2').map{ |c| c / 10.0 } + end + + def fetch_coords_old(id, index, frame, step = 1) + poslog = @raw_coords[id]&.[](index) + return nil if !poslog || poslog.length / 4 < frame + 1 + f = [frame + step - 1, poslog.length / 4 - 1].min + poslog[4 * f, 4].unpack('s<2').map{ |c| c / 10.0 } + end + + # Rescale coordinates (which are given in game units) for drawing + def rescale(pos, ppc = @ppc) + ppc == 0 ? pos : pos.map{ |c| (c * ppc * 4.0 / Map::UNITS).round } + end + + # Measure the complexity of a run by adding up all coordinates of moving entities + # Used for limiting when full animations can be done + def compute_complexity(silent: false) + @complexity = @raw_chunks.map{ |id, hash| + hash.map{ |index, chunks| + chunks[1].sum + }.sum + }.sum + dbg("NSim coordinates: %d" % [@complexity]) unless silent + end +end + +# Pack the rank and replay ID of a mappack score into a single (signed) integer +# that is sent to the game. We use the lower 24 bits for the replay ID, which +# allows IDs up to ~16M, and the higher 7 bits for the rank, which allows ranks +# up to 128, although we only need 20 (since we only ever send 1 page at a time) +def pack_replay_id(rank, replay_id) + rank << REPLAY_ID_BITS | replay_id +end + +# Unpack the rank and replay ID from a single integer. See previous function. +def unpack_replay_id(id) + [id >> REPLAY_ID_BITS, id & ((1 << REPLAY_ID_BITS) - 1)] +end + +# Compute the date of the next lotd/eotw/cotm +def next_lotd_time(type, ctp: false) + time = GlobalProperty.get_next_update(Level, ctp) + time += 1.day while time.wday != 0 if type == Episode + time += 1.day while time.day != 1 if type == Story + time +end + +# Compute the date of the previous lotd/eotw/cotm +def prev_lotd_time(type, ctp: false) + time = GlobalProperty.get_next_update(Level, ctp) - 1.day + time -= 1.day while time.wday != 0 if type == Episode + time -= 1.day while time.day != 1 if type == Story + time +end + +# Perform a request to Steamworks API using our Python tool which depends on Gobot1234/steam.py +# Note that different requests require different arguments +def steamworks( + app, # App ID, for N++ it's 230270 + username: nil, # Steam username when login is required (see token) + password: nil, # Steam password when login is required (see token) + token: nil, # Refresh token to login without username+password (preferred) + ticket: nil, # App ownership ticket to reuse when generating authentication tickets + file: nil, # File path to store output (defaults to STDOUT) + branch: nil, # App branch name + depot: nil, # App depot ID + manifest: nil, # App manifest ID + dry: false, # Dry run, only connects and verifies supplied ticket + info: false, # Fetch app info + silent: true, # Disable logging to STDOUT, except for output itself + verbose: false, # Enable verbose logging to STDOUT + timeout: 10, # Timeout in seconds before aborting (the API sometimes hangs) + retries: 5 # Retries in case of timeout before desisting altogether + ) + path = "#{PATH_STEAM_AUTH} #{app}" + path << ' -d' if dry + path << ' -i' if info + path << ' -s' if silent + path << ' -v' if verbose + path << " -U #{username}" if username + path << " -P #{password}" if password + path << " -T #{token}" if token + path << " -O #{ticket}" if ticket + path << " -F #{file}" if file + path << " -B #{branch}" if branch + path << " -D #{depot}" if depot + path << " -M #{manifest}" if manifest + + attempts = 0 + while (attempts += 1) <= retries + # Separate logins to Steamworks by at least 30 seconds + # TODO: Instead we should considering opening a single login in the background + elapsed = Time.now.to_f - $last_steamworks.to_f + if elapsed < STEAM_COOLDOWN + dbg("Steamworks cooldown: Waiting for %.1fs" % [STEAM_COOLDOWN - elapsed]) + sleep(STEAM_COOLDOWN - elapsed) + end + $last_steamworks = Time.now + + # Perform request, retry only in case of timeout + res = python(path, output: true, timeout: timeout) + return res if !res || res[2].exitstatus != TIMEOUT_CODE_TIMEDOUT + end +end + +# Parse a Steam refresh token (JWT) and verify validity +def parse_steam_jwt(data, steam_id = nil, silent: true) + header, payload = data.split('.').take(2).map{ |p| JSON.parse(Base64.urlsafe_decode64(p)) } + lower = Time.at(payload['nbf']) + upper = Time.at(payload['exp']) + now = Time.now + correct = + active = lower <= now && now <= upper + date_format = '%F' + args = [ + payload['sub'], + Time.at(payload['iat']).strftime(date_format), + lower.strftime(date_format), + upper.strftime(date_format), + payload['ip_subject'] + ] + dbg("Token for %s: issued %s, valid from %s to %s, IP %s" % args) unless silent + { + valid: payload['iss'] == 'steam' && (!steam_id || payload['sub'].to_i == steam_id), + expired: lower > now || now > upper, + expires: upper + } +end + +# <----------------------------------------------------------------------------> +# <------ (SEC11) GRAPHICS ------> +# <----------------------------------------------------------------------------> + +# A nice looking progress bar. The style can be split, filled or ascii. +def progress_bar(cur, tot, size: 20, style: :split, single: true) + return '' if tot < 0 + size += 1 if single + cur = cur.clamp(0, tot) + full = (cur * size / tot).to_i + + case style + when :split + full_char = '🔘' + empty_char = '▬' + when :filled + full_char = '■' + empty_char = '□' + else + full_char = '#' + empty_char = '-' + end + + if single + empty_char * [full, size - 1].min + full_char + empty_char * [(size - full - 1), 0].max + else + full_char * full + empty_char * (size - full) + end +end + +# Transform a table (2-dim array) into a text table +# Individual entries can be either strings or numbers: +# - Strings will be left-aligned +# - Numbers will be right-aligned +# - Floats will also be formatted with 3 decimals +# Additionally, all entries will be padded. +# An entry could also be the symbol :sep, will will insert a separator in that row +def make_table( + rows, # List of rows, each being an array of fields + header = nil, # Add a header to the table as a row with a single column + sep_x = nil, # Explicitly set the horizontal border character + sep_y = nil, # Explicitly set the vertical border character + sep_i = nil, # Explicitly set the intersection character + heavy: false, # Use the heavy variants of the table borders + double: false, # Use the double-lined variants of the table borders + hor_pad: true, # Add one space between the cell border and its contents + color_b: nil, # Color for the table border + color_h: nil, # Color for the header text + color_1: nil, # Color for the even rows' contents + color_2: nil # Color for the odd rows' contents + ) + # Convert all non-integer numbers to floats + rows.each{ |r| + next unless r.is_a?(Array) + r.map!{ |e| e.nil? ? '' : e.is_a?(Numeric) && !e.integer? ? e.to_f : e } + } + + # Compute column widths + text_rows = rows.select{ |r| r.is_a?(Array) } + count = text_rows.map(&:size).max + rows.each{ |r| if r.is_a?(Array) then r << "" while r.size < count end } + widths = (0..count - 1).map{ |c| text_rows.map{ |r| (r[c].is_a?(Float) ? "%.3f" % r[c] : ANSI.unesc(r[c].to_s)).length }.max } + length = widths.sum + (hor_pad ? 2 * widths.size : 0) + widths.size + 1 + + # Build connectors + ver = sep_y ? sep_y : double ? '║' : heavy ? '┃' : '│' + hor = sep_x ? sep_x : double ? '═' : heavy ? '━' : '─' + up_left = sep_i ? sep_i : double ? '╔' : heavy ? '┏' : '┌' + up_mid = sep_i ? sep_i : double ? '╦' : heavy ? '┳' : '┬' + up_right = sep_i ? sep_i : double ? '╗' : heavy ? '┓' : '┐' + mid_left = sep_i ? sep_i : double ? '╠' : heavy ? '┣' : '├' + mid_mid = sep_i ? sep_i : double ? '╬' : heavy ? '╋' : '┼' + mid_right = sep_i ? sep_i : double ? '╣' : heavy ? '┫' : '┤' + down_left = sep_i ? sep_i : double ? '╚' : heavy ? '┗' : '└' + down_mid = sep_i ? sep_i : double ? '╩' : heavy ? '┻' : '┴' + down_right = sep_i ? sep_i : double ? '╝' : heavy ? '┛' : '┘' + sep_up = up_left + widths.map{ |w| hor * (w + (hor_pad ? 2 : 0)) }.join(up_mid) + up_right + sep_mid = mid_left + widths.map{ |w| hor * (w + (hor_pad ? 2 : 0)) }.join(mid_mid) + mid_right + sep_down = down_left + widths.map{ |w| hor * (w + (hor_pad ? 2 : 0)) }.join(down_mid) + down_right + clean_up = up_left + widths.map{ |w| hor * (w + (hor_pad ? 2 : 0)) }.join(hor) + up_right + clean_mid = mid_left + widths.map{ |w| hor * (w + (hor_pad ? 2 : 0)) }.join(hor) + mid_right + clean_down = mid_left + widths.map{ |w| hor * (w + (hor_pad ? 2 : 0)) }.join(up_mid) + mid_right + + # Optionally, add color + color = color_h || color_b || color_1 || color_2 + clear_line = (color ? ANSI.clear : '') + "\n" + if color + color_h = ANSI.none if !color_h + color_b = ANSI.none if !color_b + color_1 = ANSI.none if !color_1 + color_2 = ANSI.none if !color_2 + + ver = color_b + ver + sep_up = color_b + sep_up + sep_mid = color_b + sep_mid + sep_down = color_b + sep_down + clean_up = color_b + clean_up + clean_mid = color_b + clean_mid + clean_down = color_b + clean_down + else + color_h, color_b, color_1, color_2 = [''] * 4 + end + + # Build header + table = '' + if !!header + header = ' ' + header + ' ' + table << clean_up + clear_line + table << ver + color_h + ANSI.bold + header.center(length - 2, '░') + ANSI.clear + ver + clear_line + table << clean_down + clear_line + else + table << sep_up + clear_line + end + + # Build table rows + even = true + sp = hor_pad ? ' ' : '' + rows.each{ |r| + next table << sep_mid + clear_line if r == :sep + clr = color ? (even ? color_1 : color_2) : '' + r.each_with_index{ |s, i| + if s.is_a?(String) + og_fmt = ANSI.get_esc(s) + s = ANSI.unesc(s) + end + sign = s.is_a?(Numeric) || is_float(ANSI.unesc(s)) ? '' : '-' + fmt = s.is_a?(Integer) ? 'd' : s.is_a?(Float) ? '.3f' : 's' + table << ver + sp + "#{og_fmt || clr}%#{sign}#{widths[i]}#{fmt}" % s + sp + table << ANSI.none if og_fmt && !color + } + table << ver + clear_line + even = !even + } + table << sep_down + clear_line + + return table +end + +# Construct a simple text-based histogram (requires Unicode support) +def make_histogram( + values, # List of pairs (value, amount) + title: 'Histogram', # Title of the histogram, will appear at the top + steps: 10, # Rough number of steps in the Y axis + hor_step: 5, # X axis grid lines separation (also for labels) + vert_step: 4, # Y axis grid lines separation + grid: false, # Whether to draw the grid + frame: true, # Whether to frame the histogram in a rectangle + labels: true, # Whether to write X axis labels + color_graph: ANSI.blue, # ANSI color for the graph bars + color_lines: ANSI.magenta, # ANSI color for the lines (frame and axes) + color_text: ANSI.green # ANSI color for the axes labels + ) + max_y = values.max_by(&:last).last + min_y = 0 #values.min_by(&:last).last + range_y = max_y - min_y + if range_y <= steps + step = 1 + steps = range_y + else + step = range_y / steps.to_f + exp = Math.log10(step).floor + base_step = step / 10 ** exp + base_steps = [1, 2, 5] + step = base_steps.min_by{ |s| (s - base_step).abs } * 10 ** exp + steps = (range_y / step.to_f).round + end + heights = values.map{ |_, n| (n / step.to_f).round } + pad = range_y >= 1 ? Math.log10(range_y).floor + 1 : 0 + width = pad + 3 + values.size + histogram = "" + histogram << ANSI.bold + title.center(width + (frame ? 4 : 0)) + ANSI.clear << "\n" if title + histogram << (color_lines || '') << '┌─' << '─' * width << '─┐' << "\n" if frame + steps.times.reverse_each do |s| + histogram << (color_lines || '') << '│ ' if frame + histogram << (color_text || '') << "%*d" % [pad, step * (s + 1)] << (color_lines || '') << " │ " + histogram << color_graph if color_graph + is_row = grid && (s + 1) % vert_step == 0 + heights.each_with_index{ |h, i| + is_col = grid && i % hor_step == 0 + empty_char = is_row ? (is_col ? '·' : '·') : (is_col ? '.' : ' ') + histogram << (h >= (s + 1) ? '▌' : empty_char) + } + histogram << (color_lines || '') << ' │' if frame + histogram << "\n" + end + x_axis = '─' * values.size + i = -hor_step + x_axis[i += hor_step] = '╥' while i < x_axis.size - hor_step if labels + histogram << (frame ? (color_lines || '') + '│ ' : '') << '─' * pad + '─┴─' + x_axis << (frame ? ' │' : '') + label_row = ' ' * x_axis.size + if labels + x_axis.each_char.with_index{ |c, i| + next if c == '─' + label = values[i][0].to_s + label_row[i - label.size / 2, label.size] = label + } + label_row.prepend(' ' * (pad + 3)) + histogram << "\n" << (frame ? '│ ' : '') << (color_text || '') << label_row << (frame ? ((color_lines || '') + ' │') : '') + end + histogram << "\n" << (color_lines || '') << '└─' << '─' * width << '─┘' if frame + histogram << ANSI.clear + histogram +end + +def create_svg( + title: 'Plot', + x_name: 'X', + y_name: 'Y', + x_res: 1920, + y_res: 1080, + data: [[]], + names: [], + labels: [], + fmt: '%d' + ) + titles = title.split("\n") + # There are more options available: + # https://github.com/lumean/svg-graph2/blob/master/lib/SVG/Graph/Graph.rb + options = { + # Geometry + width: x_res, + height: y_res, + stack: :side, # The stack option is valid for Bar graphs only + + # Title + show_graph_title: true, + graph_title: titles[0], + show_graph_subtitle: titles.size > 1, + graph_subtitle: titles[1], + + # Axis + show_x_title: true, + x_title: x_name, + x_title_location: :middle, + show_y_title: true, + y_title: y_name, + y_title_location: :end, + y_title_text_direction: :bt, # :bt, :tb + + # Legend + key: true, + key_width: nil, + key_position: :right, # :bottom, :right + + # X labels + fields: labels, + show_x_labels: true, + stagger_x_labels: false, + rotate_x_labels: false, + step_x_labels: 1, + step_include_first_x_label: true, + show_x_guidelines: false, + + # Y labels + show_y_labels: true, + rotate_y_labels: false, + stagger_y_labels: false, + show_y_guidelines: true, + + # Fonts + font_size: 12, + title_font_size: 16, + subtitle_font_size: 14, + x_label_font_size: 12, + y_label_font_size: 12, + x_title_font_size: 14, + y_title_font_size: 14, + key_font_size: 10, + key_box_size: 12, + key_spacing: 5, + + # Other + number_format: fmt, + scale_divisions: (data.map(&:max).max.to_f / 6).round, + scale_integers: true, + no_css: false, + bar_gap: false, + show_data_values: false, + + # Line/Plot specific + area_fill: true, + show_data_points: false + } + g = SVG::Graph::Line.new(options) + data.each_with_index{ |plot, i| + g.add_data({data: plot, title: names[i].to_s}) + } + g.burn_svg_only +end + +# Parse a TGA image and read the pixel data (very incomplete) +# Unpacking takes some time, but makes comparisons faster. If more than +# 13k pixels need to be used (~200 font chars at 11px), it's faster. +def parse_tga(file, unpack: true) + tga = File.binread(file) + perror("Invalid TGA file.") if tga.size < 18 + + # Parse header + id_length, colormap_type, image_type = tga.unpack('C3') + cm_index, cm_length, cm_size = tga[3 ... 8].unpack('S<2C') + x, y, width, height, depth, desc = tga[8 ... 18].unpack('S<4C2') + + return [] if image_type == 0 + perror("Color-mapped TGA files not supported.") if colormap_type != 0 + perror("RLE TGA files not supported.") if image_type > 3 + perror("True color TGA files not supported.") if image_type < 3 + perror("Reverse-storage TGAs not supported.") unless desc[5] == 1 + alert("Trying to parse a sub-8bit depth TGA.") if depth < 8 + + # Parse image data + offset = 18 + id_length + cm_length + perror("Incomplete TGA image data.") if tga.size < offset + width * height + image_data = tga[offset ... offset + width * height] + image_data = image_data.unpack('C*') if unpack + + image_data +rescue => e + lex(e, 'Failed to parse TGA file.') + nil +end + +# Parses a BMFont-generated .fnt file to read a bitmap font +def parse_bmfont(name) + file = File.join(DIR_FONTS, name + '.fnt') + perror("Font file not found.") if !File.file?(file) + + font = File.read(file).split("\n").map{ |l| + [ + l[/^\w+/], + l.scan(/(\w+)=(?:(?:"((?:[^"\\]|\\.)*)")|(\S+))/).map(&:compact).map{ |k, v| + [k, v =~ /^-?\d+$/ ? v.to_i : v] + }.to_h + ] + }.group_by(&:first).map{ |k, v| [k, v.map(&:last)] }.to_h + font['char'] = font['char'].map{ |c| [c['id'], c] }.to_h + font['pages'] = {} + font['page'].each{ |page| + file = File.join(DIR_FONTS, page['file']) + perror("Font TGA not found.") if !File.file?(file) + font['pages'][page['id'].to_i] = parse_tga(file) + } + font +rescue => e + lex(e, 'Failed to parse BMFont.') + nil +end + +# Add a string of text to a Gifenc::Image using a BMFont +# TODO: Implement bound checks, wrapping logic, limit text to a certain bbox, +# rolling text (usng modulo), vertical text... and add to Gifenc +def txt2gif(str, image, font, x, y, color, pad_x: 0, pad_y: 0, wrap: false, align: :left, max_length: nil, max_lines: nil, max_width: nil, max_height: nil) + # Parse GIF image and font texture + image_width = image.width + image_height = image.height + texture_width = font['common'][0]['scaleW'] + texture_height = font['common'][0]['scaleH'] + wildcard = font['char']['?'.ord] + + # Init params + color = color.chr if color.is_a?(Integer) + factor = { left: 0, center: 0.5, right: 1 }[align] + start_x = x - strlen(str, font) * factor + start_y = y - font['common'][0]['base'] + 1 + max_width = [max_width, image_width - start_x].compact.min + max_height = [max_height, image_height - start_y].compact.min + max_length = max_width unless max_length + max_lines = max_height unless max_lines + + # Render each character + cursor_x = start_x + cursor_y = start_y + str.each_codepoint.with_index{ |c, i| + # Fetch canvas offsets + char = font['char'][c] || wildcard + image_x = cursor_x + char['xoffset'] + image_y = cursor_y + char['yoffset'] + texture = font['pages'][char['page']] + texture_off = texture_width * char['y'] + char['x'] + break if image_x + char['width'] - start_x > max_width || i >= max_length + + # Paint each pixel + char['height'].times.each{ |y| + char['width'].times.each{ |x| + if texture[texture_off] > 0 + image[image_x, image_y] = color + end + texture_off += 1 + image_x += 1 + } + texture_off += texture_width - char['width'] + image_y += 1 + image_x -= char['width'] + } + + # Advance cursor + cursor_x += char['xadvance'] + pad_x + } + # cursor_y += font['common'][0]['lineHeight'] + pad_y + + image +rescue => e + lex(e, 'Failed to render text in GIF from TGA font.') + image +end + +# Find the pixel length of a string in the given font +def strlen(str, font, pad_x: 0) + length = 0 + str.each_codepoint{ |c| + char = font['char'][c] || font['char']['?'.ord] + length += char['xadvance'] + pad_x + } + length +end + +# Convert a raw octicon to a valid SVG file +def build_octicon(name, size = 24, color = 'white') + # Ensure defaults are used even if nil is explicitly sent + size ||= 24 + color ||= 'white' + icon = Octicons::Octicon.new(name, width: size) rescue Octicons::Octicon.new('question', width: size) + w = icon.options[:width] + h = icon.options[:height] + box = icon.options[:viewBox] + color.prepend('#') if (color.length == 3 || color.length == 6) && color == color[/\h+/i] + %{ + + #{icon.path} + + } +end + +# <----------------------------------------------------------------------------> +# <------ (SEC12) BOT MANAGEMENT ------> +# <----------------------------------------------------------------------------> + +# Permission system: +# Support for different roles (unrelated to Discord toles). Each role can +# be determined by whichever system we choose (Discord user IDs, Discord +# roles, etc.). We can restrict each function to only specific roles. +# +# Currently implemented roles: +# 1) botmaster: Only the manager of the bot can execute them (matches +# Discord's user ID with a constant). This role has permission +# to perform any other task as well. +# 2) dmmc: For executing the function to batch-generate screenies of DMMC. +# 3) ntracer: Those who can update ntrace. +# +# The following functions then check if the user who tried to execute a +# certain function belongs to any of the permitted roles for it. +def check_permission(event, role) + case role + when 'botmaster' + { + granted: is_botmaster?(event), + allowed: ['botmasters'] + } + else + { + granted: Role.exists(event.user.id, role) || is_botmaster?(event), + allowed: role.pluralize #names + } + end +end + +def assert_permissions(event, roles = []) + roles.push('botmaster') # Can do everything + permissions = roles.map{ |role| check_permission(event, role) } + granted = permissions.map{ |p| p[:granted] }.count(true) > 0 + error = "Sorry, only #{permissions.map{ |p| p[:allowed] }.flatten.to_sentence} are allowed to execute this command." + perror(error) if !granted +rescue + perror("Permission check failed") +end + +# This function sets up the potato parameters correctly in case outte was closed, +# so that the food chain may not be broken +def fix_potato + last_msg = $nv2_channel.history(1)[0] rescue nil + $last_potato = last_msg.timestamp.to_i rescue Time.now.to_i + if last_msg.author.id == $config['discord_client'] + $potato = ((FOOD.index(last_msg.content) + 1) % FOOD.size) rescue 0 + end +rescue + nil +end + +# Set global variables holding references to the main Discord channels the bot uses +def set_channels(event = nil) + if !event.nil? + $channel = event.channel + $mapping_channel = event.channel + $nv2_channel = event.channel + $content_channel = event.channel + $speedrun_channel = event.channel + $ctp_channel = event.channel + elsif !TEST + channels = $bot.servers[SERVER_ID].channels + $channel = channels.find{ |c| c.id == CHANNEL_HIGHSCORES } + $mapping_channel = channels.find{ |c| c.id == CHANNEL_USERLEVELS } + $nv2_channel = channels.find{ |c| c.id == CHANNEL_NV2 } + $content_channel = channels.find{ |c| c.id == CHANNEL_CONTENT } + $speedrun_channel = channels.find{ |c| c.id == CHANNEL_SPEEDRUNNING } + $ctp_channel = channels.find{ |c| c.id == CHANNEL_CTP_HIGHSCORES } + else + return + end + fix_potato + log("Main channel: #{$channel.name}") if !$channel.nil? + log("Mapping channel: #{$mapping_channel.name}") if !$mapping_channel.nil? + log("Nv2 channel: #{$nv2_channel.name}") if !$nv2_channel.nil? + log("Content channel: #{$content_channel.name}") if !$content_channel.nil? + log("Speedrun channel: #{$speedrun_channel.name}") if !$speedrun_channel.nil? + log("CTP channel: #{$ctp_channel.name}") if !$ctp_channel.nil? +end + +# Verify available Steam refresh tokens and possibly warn the botmaster +def verify_steam_tokens + $steam_tokens.each{ |steam_id, hash| + hash.merge!(parse_steam_jwt(hash[:token], steam_id)) + next if hash[:warned] + str = "Steam token for #{steam_id}" + rem = hash[:expires] - Time.now + hash[:warned] = true + next err("#{str} is invalid", discord: true) if !hash[:valid] + next err("#{str} expired on #{hash[:expires].strftime('%F %T')}", discord: true) if rem < 0 + next alert("#{str} expires in #{rem / 86400} days", discord: true) if rem < STEAM_TOKEN_GRACE * 86400 + hash[:warned] = false + } +end + +# Leave all the servers the bot is in which are not specifically white-listed +# +# This is used because, in rare cases, 3rd parties could add outte to their +# Discord servers, because it's a public bot (otherwise, the botmaster would +# need mod powers in all servers the bot is in). +def leave_unknown_servers + names = [] + $bot.servers.each{ |id, s| + if !SERVER_WHITELIST.include?(id) + names << s.name + s.leave + end + } + alert("Left #{names.count} unknown servers: #{names.join(', ')}") if names.count > 0 +end + +def update_bot_status + $bot.update_status(BOT_STATUS, BOT_ACTIVITY, nil, 0, false, 0) +end + +# Schedule a restart as soon as possible, i.e., as soon as no maintainance tasks +# are being executed, like publishing lotd or downloading the scores. +# If force is true, the threads will be killed immediately. +def restart(reason = 'Unknown reason', force: false) + alert("Restarting outte due to: #{reason}.", discord: true) + shutdown(trap: false, force: force) + exec('./inne') +rescue => e + lex(e, 'Failed to restart outte', discord: true) + sleep(5) + retry +end + +# <----------------------------------------------------------------------------> +# <------ (SEC13) SQL ------> +# <----------------------------------------------------------------------------> + +# This function needs to be called whenever we've spent a "long time" without +# querying the database, since we may've been disconnected due to inactivity. +# In practice, we call it before the initial request on every command, and +# after threading/forking. +def acquire_connection + sql("SELECT 1") + #ActiveRecord::Base.connection.reconnect! +rescue + false +else + true +end + +# Release a db connection to prevent the pool from filling with zombie connections. +# We presumably are also reaping idle connections regularly, but this is just in case. +def release_connection + ActiveRecord::Base.connection_pool.release_connection + #ActiveRecord::Base.connection.disconnect! +rescue + nil +end + +# Shorthand to enclose a function call ensuring that we acquire a db connection +# at the start and release it at the end. +def with_connection(&block) + return if !block_given? + acquire_connection + res = yield + release_connection + res +end + +# Perform arbitrary raw SQL commands +def sql(command) + ActiveRecord::Base.connection.exec_query(command) rescue nil +end + +# Fetch value of certain MySQL variables and statuses +def update_sql_status + $sql_vars = sql("SHOW SESSION VARIABLES").rows.to_h + $sql_status = sql("SHOW GLOBAL STATUS").rows.to_h + $sql_conns = sql("SHOW FULL PROCESSLIST").to_a +end + +# Checks if current Rails version is at least the provided one +def rails_at_least(ver) + ActiveRecord.version >= Gem::Version.create(ver) +end + +# Checks if current Rails version is at most the provided one +def rails_at_most(ver) + ActiveRecord.version <= Gem::Version.create(ver) +end + +# Creates an enum in Rails. The syntax changed in Rails 7 from keyword to +# positional arguments. The content can be provided as an array of symbols, in +# which case the corresponding values will start at 0. Explicit values can +# be provided if a hash is used instead. An optional hash of options can be +# provided. +def create_enum(name, values, opts = {}) + if rails_at_least('7.0.0') + enum(name, values, **opts) + else + opts[name] = values + enum(opts) + end +end + +# <----------------------------------------------------------------------------> +# <------ (SEC14) MATHS ------> +# <----------------------------------------------------------------------------> + +# Floating point precision. It's the minimum threshold to consider two floats different. +FLT_PREC = 1E-7 + +# Convert an arbitrary vector given by its two components to a direction scalar +# ranging from -4 to 4. If it's an integer, it means the direction is a perfect +# multiple of PI/4. +def vec2dir(x, y) + 4 * Math::atan2(y, x) / Math::PI +end + +# Convert a direction (float, -4 to 4) to an orientation (integer, 0 to 7), which +# is how N++ represents orientations in map data. +def dir2or(dir) + dir.round % 8 +end + +# Convert a vector to an orientation +def vec2or(x, y) + dir2or(vec2dir(x, y)) +end + +# Convert an N++ orientation (0 to 7) to the correspoding unit direction vector +def or2vec(o) + r = 2 ** 0.5 + [[1, 0], [r, r], [0, 1], [-r, r], [-1, 0], [-r, -r], [0, -1], [r, -r]][o] +end + +# Compute the left normal vector +def lnorm(x, y) + [-y, x] +end + +# Compute the right normal vector +def rnorm(x, y) + [y, -x] +end + +# Compute the Euclidean norm of a vector +def vecnorm(x, y) + (x * x + y * y) ** 0.5 +end + +# Check if two numbers are equal, up to the floating precision +def num_eql?(x, y) + (x - y).abs < FLT_PREC +end + +# Check if a vector is unitary +def is_unit(x, y) + num_eql?(x * x + y * y, 1) +end + +# Check if a floating point number is an integer +def is_int(x) + num_eql?(x, x.round) +end + +# Weighted average +def wavg(arr, w) + return -1 if arr.size != w.size + arr.each_with_index.map{ |a, i| a*w[i] }.sum.to_f / w.sum +end + +# Length of the string representing a number +def numlen(n, float = true) + n.to_i.to_s.length + (float ? 4 : 0) +end + +# Used for correcting a datetime in the database when it's out of phase +# (e.g. after a long downtime of the bot). +def correct_time(time, frequency) + time -= frequency while time > Time.now + time += frequency while time < Time.now + time +end + +# From now on, a bbox (short for bounding box) is a rectangle given in the form +# [X, Y, W, H], where [X, Y] are the coordinates of its upper left corner, and +# [W, H] are its dimensions. + +# Compute the intersection of a list of bboxes. Returns a bbox if it exists, or +# nil otherwise. +def bbox_intersect(bboxes, round: false) + bboxes.compact! + return nil if bboxes.empty? + x1 = bboxes.map{ |bbox| bbox[0] }.max + y1 = bboxes.map{ |bbox| bbox[1] }.max + x2 = bboxes.map{ |bbox| bbox[0] + bbox[2] }.min + y2 = bboxes.map{ |bbox| bbox[1] + bbox[3] }.min + x1, y1, x2, y2 = x1.round, y1.round, x2.round, y2.round if round + w = x2 - x1 + h = y2 - y1 + [w, h].min > 0.01 ? [x1, y1, w, h] : nil +end + +# Compute the rectangular hull of a list of bboxes. +def bbox_hull(bboxes, round: false) + bboxes.compact! + return nil if bboxes.empty? + x1 = bboxes.map{ |bbox| bbox[0] }.min + y1 = bboxes.map{ |bbox| bbox[1] }.min + x2 = bboxes.map{ |bbox| bbox[0] + bbox[2] }.max + y2 = bboxes.map{ |bbox| bbox[1] + bbox[3] }.max + x1, y1, x2, y2 = x1.round, y1.round, x2.round, y2.round if round + w = x2 - x1 + h = y2 - y1 + [w, h].min > 0.01 ? [x1, y1, w, h] : nil +end + +# Computes the area of a bbox. +def bbox_area(bbox) + bbox ? bbox[2] * bbox[3] : 0 +end + +# Compute the SHA1 hash. It uses Ruby's native version, unless 'c' is specified, +# in which case it uses our external C function that implements STB's version. +# It transforms the result to an ASCII hex string if 'hex' is specified. +# +# This is done, not for speed, but because the implementations differ, and +# the STB one is the exact one used by N++, so it's the one we need to verify +# the integrity of the hashes generated by the game. +def sha1(data, c: false, hex: false) + hash = c && $c_inne ? c_stb_sha1(data) : Digest::SHA1.digest(data) + hex ? hash.unpack('H*')[0] : hash +rescue => e + lex(e, 'Failed to compute SHA1 hash') + nil +end + +def md5(data, hex: false) + hash = Digest::MD5.digest(data) + hex ? hash.unpack('H*')[0] : hash +rescue => e + lex(e, 'Failed to compute MD5 hash') + nil +end + +# Done loading file +dbg("Loaded #{File.basename(__FILE__)} in %dms" % [(Time.now - $load_time) * 1000.0]) diff --git a/test/factories/level.rb b/test/factories/level.rb index b312d45a..69239991 100644 --- a/test/factories/level.rb +++ b/test/factories/level.rb @@ -1,4 +1,4 @@ -FactoryGirl.define do +FactoryBot.define do sequence(:name) { |n| "SI-A-00-#{n}" } factory :level do diff --git a/test/factories/player.rb b/test/factories/player.rb index 8e9ca220..7c67a17f 100644 --- a/test/factories/player.rb +++ b/test/factories/player.rb @@ -1,4 +1,4 @@ -FactoryGirl.define do +FactoryBot.define do factory :player do name 'High Priest o the Righteous Feed' end diff --git a/test/factories/score.rb b/test/factories/score.rb index 96e7293e..b492d163 100644 --- a/test/factories/score.rb +++ b/test/factories/score.rb @@ -1,4 +1,4 @@ -FactoryGirl.define do +FactoryBot.define do factory :score do association :highscoreable, factory: :level association :player, factory: :player diff --git a/util/auth.py b/util/auth.py new file mode 100755 index 00000000..44835208 --- /dev/null +++ b/util/auth.py @@ -0,0 +1,462 @@ +import argparse, asyncio, base64, datetime, enum, json, os, steam, steam.gateway, struct, sys, textwrap, traceback, typing + +# Helpers +def write(str, symb): + if not args.silent: + print(f"[{symb}] {str}") +def dbg(str): + if args.verbose: + write(str, '+') +def log(str): write(str, '-') +def warn(str): write(str, '!') +def date(d): return d.isoformat() if d else 'Unknown' +def time(t): return date(datetime.datetime.fromtimestamp(t)) +def serial(bytes: bytes) -> str: return bytes.hex().upper() + +# Exiting the program +class ExitCode(enum.Enum): + OK = 0 + NO_CREDENTIALS = 1 + NO_OWNERSHIP_TICKET = 2 + NO_AUTHENTICATION_TICKET = 3 + +async def end(code: ExitCode = ExitCode.OK) -> typing.NoReturn: + match code: + case ExitCode.OK: + log("Exited normally") + case ExitCode.NO_CREDENTIALS: + warn("No valid login credentials found, supply them via refresh token or username and password.") + case ExitCode.NO_OWNERSHIP_TICKET: + warn("Invalid ownership ticket supplied / fetched") + case ExitCode.NO_AUTHENTICATION_TICKET: + warn("Failed to generate valid authentication ticket") + if code != ExitCode.NO_CREDENTIALS: + await client.disconnect() + raise SystemExit(code.value) + +def save(data: str): + if args.file: + with open(args.file, 'w') as f: + f.write(data + '\n') + else: + print(data) + +# Argument parsing +parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent(""" + - Simple Steam App Authenticator - + Generates and activates a session authentication ticket for the given app via Steam's API, + this cancels any outstanding tickets for the same user/app pair, and expires in ~5 minutes. + Login can be performed via refresh token or username + password, both of which can be supplied + as arguments or as environment variables. An ASCII ownership ticket can be supplied, otherwise + a new one will be generated, but they typically have a lifetime of 21 days so its recommended + to cache them and pass them whenever possible. Requires Python 3.11+. See below for more info. + Usage hint: Boolean flags are lower-case, flags that require a value are upper-case. + """), + epilog=textwrap.dedent(""" + Session tickets are normally obtained via Steamwork's GetAuthSessionTicket method and need to + be supplied to servers in order to authenticate and prove ownership of the game. This allows + the server to start an authenticated session with BeginAuthSession. The ticket is composed of + 3 elements: The Game Connect (GC) token, the Session Header, and the Ownership Ticket. + + - The Game Connect token (24 bytes) is unique per session and should be used immediately, + once validated it expires in about 5 mins. Steam issues 10 GC tokens on login so they + can be consumed as needed, as well as additional tokens when we join a game. + + - The Session Header (28 bytes) contains session information that is not validated, such + as the current connection's duration or the external IP. + + - The Ownership Ticket constitutes the rest of the payload, being at least 46 bytes, plus + 128 bytes for the signature. It expires in 21 days and thus, while not necessary, it + should be preserved and reused. This can be done by stripping the first 56 bytes of the + full ticket (GC token + session header + ownership ticket size). The signature is based + on RSA-SHA1 and can be verified with Steam's public System key: + + MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQDf7BrWLBBmLBc1OhSwfFkRf53T + 2Ct64+AVzRkeRuh7h3SiGEYxqQMUeYKO6UWiSRKpI2hzic9pobFhRr3Bvr/WARvY + gdTckPv+T1JzZsuVcNfFjrocejN1oWI0Rrtgt4Bo+hOneoo3S57G9F1fOpn5nsQ6 + 6WOiu4gZKODnFMBCiQIBEQ== + + For automated logins it's advised to use a refresh token. This will be fetched when you log + in with username and password in verbose mode, and allows to re-login without providing them + again. It's a Base64-encoded signed JWT (JSON Web Token) that expires in ~200 days and + specifies your Steam ID and IP, among other fields. + """) +) +parser.add_argument('app', type=int, help='Steam app ID (e.g. 440 for TF2)') +parser.add_argument('-B', '--branch', type=str, help='Branch name, for fetching build or manifest info') +parser.add_argument('-c', '--connect', action='store_true', help='Stay connected after exporting tickets (will require an Interrupt to close)') +parser.add_argument('-D', '--depot', type=int, help='Depot ID, for fetching build or manifest info') +parser.add_argument('-d', '--dry', action='store_true', help='Performs a dry run (logs in a verifies supplied ticket)') +parser.add_argument('-F', '--file', type=str, help='Export tickets to this file instead of STDOUT') +parser.add_argument('-i', '--info', action='store_true', help='Only fetch game info, implies -d') +parser.add_argument('-M', '--manifest', type=int, help='Manifest ID to fetch details from') +parser.add_argument('-O', '--ticket', type=str, help='App ownership ticket to attempt to reuse (also SSAA_TICKET)') +parser.add_argument('-P', '--password', type=str, help='Steam password used for login (also SSAA_PASSWORD env var)') +parser.add_argument('-U', '--username', type=str, help='Steam username used for login (also SSAA_USERNAME env var)') +parser.add_argument('-s', '--silent', action='store_true', help='Supresses all STDOUT and STDERR output except for the ticket itself') +parser.add_argument('-T', '--token', type=str, help='Refresh token JWT used for login (also SSAA_TOKEN env var)') +parser.add_argument('-v', '--verbose', action='store_true', help='Print additional technical information to the terminal') +args = parser.parse_args() + +# It's very hard to properly catch exceptions inside async contexts and we need the +# terminal to be clean to export the ticket, so we optionally suppress STDERR to +# prevent exceptions raised when disconnecting from cluttering the terminal. +if args.silent: + sys.stderr.flush() + devnull = open(os.devnull, 'w') + os.dup2(devnull.fileno(), sys.stderr.fileno()) + +USERNAME: str | None = args.username or os.environ.get('SSAA_USERNAME') +PASSWORD: str | None = args.password or os.environ.get('SSAA_PASSWORD') +TOKEN: str | None = args.token or os.environ.get('SSAA_TOKEN') +TICKET: str | None = args.ticket or os.environ.get('SSAA_TICKET') + +def verify_token(token: str) -> bool: + """Decode a token and perform some sanity checks""" + dbg("Refresh token supplied:") + dbg(token) + header, payload = [json.loads(base64.urlsafe_b64decode(bytes(block + '==', 'utf-8'))) for block in token.split('.')[:2]] + dbg(f"Header: {header}") + dbg(f"Payload: {payload}") + lower = datetime.datetime.fromtimestamp(payload['nbf']) + upper = datetime.datetime.fromtimestamp(payload['exp']) + now = datetime.datetime.now() + if not (lower <= now <= upper): + warn(f"Refresh token is only valid between {date(lower)} and {date(upper)}") + return False + # No need to check Steam ID since we aren't logged in yet + dbg(f"Refresh token issued {date(datetime.datetime.fromtimestamp(payload['iat']))} valid for {payload['sub']}@{payload['ip_subject']}, expires {date(upper)}") + return True + +if TOKEN and not verify_token(TOKEN): + warn("Defaulting to username and password") + TOKEN = None + +class Bot(steam.Client): + """Handle communications via Steamworks API using Gobot1234/steam.py""" + + # < ------------ EVENT HANDLERS ------------> + + async def on_connect(self) -> None: + log(f"Connected to Steam") + + async def on_ready(self) -> None: + log(f"Logged in as {self.user.name} ({self.user.id64})") + self.log_tokens() + if not TOKEN and args.verbose: + dbg(f"Refresh token: {self.refresh_token}") + app = self.get_app(args.app) + await self.change_presence(app=app) + + # Fetch game info + if args.info: + save(json.dumps(await self.get_info(app))) + await end() + + # Fetch manifest details + if args.branch and args.depot and args.manifest: + save(json.dumps(await self.get_manifest(app, args.branch, args.depot, args.manifest))) + await end() + + # Reuse ownership ticket or fetch a new one + ticket = await self.get_ownership_ticket(app) + if not ticket: + await end(ExitCode.NO_OWNERSHIP_TICKET) + if args.dry: + await end() + + # Build and activate ticket + ticket = self.get_authentication_ticket(ticket) + if not ticket: + await end(ExitCode.NO_AUTHENTICATION_TICKET) + log("Activating ticket...") + await ticket.activate() + + # Export ticket + save(serial(bytes(ticket)[4:])) + + if not args.connect: + await end() + + async def on_disconnect(self) -> None: + log(f"Disconnected from Steam") + + async def on_user_update(self, before: steam.User, after: steam.User, /) -> None: + dbg(f"User {after.name} ({after.state.name}) updated: Playing {after.app}") + + async def on_error(self, event, error, *arg, **kwarg) -> None: + warn(f"Received error: {event} ({error})") + if not args.verbose: + return + exc_type, exc_val, exc_tb = sys.exc_info() + traceback.print_exception(exc_type, exc_val, exc_tb) + + # < ------------ TICKET MANIPULATION ------------> + + def tokens(self) -> list[bytes]: + return self._state._game_connect_bytes + + def verify_ticket(self, ticket: steam.OwnershipTicket | steam.AuthenticationTicket) -> bool: + ttype = type(ticket).__name__ + issues = 0 + if not ticket.signature or not ticket.is_signature_valid(): + warn(f"{ttype} is not properly signed by Steam") + issues += 1 + if ticket.is_expired(): + warn(f"{ttype} is expired ({date(ticket.expires)})") + issues += 1 + if ticket.app.id != args.app: + warn(f"{ttype} belongs to a different app ({ticket.app.id} vs {args.app})") + issues += 1 + if ticket.user.id64 != self.user.id64: + warn(f"{ttype} belongs to a different user ({ticket.user.id64} vs {self.user.id64})") + issues += 1 + if issues == 0: + log(f"{ttype} is correct (app {ticket.app.id}, steam id {ticket.user.id64}, expires {date(ticket.expires)})") + return issues == 0 + + async def get_ownership_ticket(self, app: steam.PartialApp) -> steam.OwnershipTicket | None: + # First try to use supplied ticket + if TICKET: + log("Attempting to use provided ownership ticket:") + dbg(TICKET) + try: + ticket = bytes.fromhex(TICKET) + ticket = steam.OwnershipTicket(self._state, steam.utils.StructIO(ticket)) + if self.verify_ticket(ticket): + self.log_ownership_ticket(ticket) + return ticket + except: + warn("Failed to parse supplied ownership ticket") + + # Otherwise, fetch a new ticket + if args.dry: + return + log("Requesting new ownership ticket...") + ticket = await self._state.fetch_app_ownership_ticket(app.id) + dbg(serial(ticket)) + ticket = steam.OwnershipTicket(self._state, steam.utils.StructIO(ticket)) + if self.verify_ticket(ticket): + self.log_ownership_ticket(ticket) + return ticket + return None + + def get_authentication_ticket(self, own: steam.OwnershipTicket) -> steam.AuthenticationTicket | None: + try: + token = self.tokens().pop(0) + except IndexError: + return None + log(f"Building authentication ticket with token {struct.unpack(' + + def log_tokens(self) -> None: + tokens = self.tokens().copy() + log(f"GC tokens found: {len(tokens)}") + if not args.verbose or len(tokens) == 0: + return + dbg(" %40s %16s %17s %19s" % ('Raw bytes', 'Token', 'Steam ID', 'Date')) + for token in tokens: + gc, steam_id, gen_time = struct.unpack('<2QL', token) + dbg(f" {serial(token)} -> {gc:016x} {steam_id} {time(gen_time)}") + + def log_ownership_ticket(self, ticket: steam.OwnershipTicket) -> None: + line = "+" + "-" * 76 + "+" + dbg(line) + dbg("|Metadata|%6s|%7s|%5s|%6s|%19s|%19s|" % ('Length', 'Version', 'Flags', 'Signed', 'Created', 'Expires')) + dbg("| |%6d|%7d|%5d|%6s|%19s|%19s|" % (len(bytes(ticket)), ticket.version, ticket.flags, 'Yes' if ticket.signature else 'No', date(ticket.created_at), date(ticket.expires))) + dbg(line) + dbg("|Profile |%17s|%6s|%15s|%15s|%5s|%4s|" % ('Steam ID', 'App ID', 'External IP', 'Internal IP', 'Lics.', 'DLCs')) + dbg("| |%17d|%6d|%15s|%15s|%5d|%4d|" % (ticket.user.id64, ticket.app.id, ticket.external_ip, ticket.internal_ip, len(ticket.licenses), len(ticket.dlc))) + if len(ticket.licenses) > 0: + dbg(line) + dbg("|Licenses|%-67s|" % (', '.join(str(lic.id) for lic in ticket.licenses),)) + if len(ticket.dlc) > 0: + dbg(line) + dbg("|DLCs |%-67s|" % (', '.join(str(dlc.id) for dlc in ticket.dlc),)) + dbg("+" + "-" * 76 + "+") + + # < ------------ INFO ------------> + + async def get_info(self, app: steam.app.PartialApp) -> dict: + fetch_count = False + fetch_stats = False + fetch_info = True + fetch_achievements = False + fetch_dlcs = False + fetch_packages = False + result = {} + + if fetch_count: + log("Fetching player count...") + result["players"] = await app.player_count() # int + + if fetch_stats: + log("Fetching app stats...") + stats = await app.stats() # AppStats + result["stats"] = { + "version": stats.version, + "stats": [ + { + "name": s.name, + "display": s.display_name, + "default": s.default_value + } for s in stats.stats + ] + } + + if fetch_info: + log("Fetching app info and branches...") + info = await app.info() # AppInfo + result["info"] = { + "id": info.id, + "changenum": info.change_number, + "name": info.name, + "sha": info.sha, + "url": info.website_url, + "developers": info.developers, + "date": date(info.created_at), + "score": info.review_score.name, + "ratio": info.review_percentage, + "free": info.is_free(), + "platforms": {"windows": info.is_on_windows(), "linux": info.is_on_linux(), "mac": info.is_on_mac_os()}, + "genres": [{"id": g.id, "name": g.name} for g in info.genres], + "categories": [{"id": c.id, "name": c.name} for c in info.categories], + "tags": [{"id": t.id, "name": t.name} for t in info.tags] + } + result["branches"] = [ + { + "name": b.name, + "build_id": b.build_id, + "date": date(b.updated_at), + "description": b.description, + "private": b.password_required, + "password": b.password, + "depots": [ + { + "id": d.id, + "name": d.name, + "max_size": d.max_size, + "shared_install": d.shared_install, + "system_defined": d.system_defined, + "manifest": d.manifest.id, + "config": {k: d.config.getall(k) for k in set(d.config.keys())} + + } for d in b.depots], + "manifests": [ + { + "id": m.id, + "name": m.name, + "depot": m.depot.id + } for m in b.manifests] + } for b in info.branches + ] + + if fetch_achievements: + log("Fetching app achievements...") + achs = await app.achievements() # List[AppAchivement] + result["achievements"] = [ + { + "name": a.name, + "display": a.display_name, + "description": a.description, + "hidden": a.hidden, + "ratio": a.global_percent_unlocked + } for a in achs + ] + + if fetch_dlcs: + log("Fetching app DLCs...") + dlcs = await app.dlc() # List[DLC] + result["dlcs"] = [ + { + "id": dlc.id, + "name": dlc.name, + "date": date(dlc.created_at), + "free": dlc.is_free, + "platforms": {"windows": dlc.is_on_windows(), "linux": dlc.is_on_linux(), "mac": dlc.is_on_mac_os()} + + } for dlc in dlcs + ] + + if fetch_packages: + log("Fetching app packages...") + packages = await app.packages() # List[FetchedAppPackage] + result["packages"] = [ + { + "id": p.id, + "name": p.name, + "url": p.url + + } for p in packages + ] + + return result + + # TODO: Retrieve chunks for fine-grained statistics and diffing? + async def get_manifest(self, app: steam.app.PartialApp, branch: str, depot_id: int, manifest_id: int) -> dict: + log(f"Fetching manifest {manifest_id} from branch {branch} and depot {depot_id}...") + manifest = await app.fetch_manifest(id=manifest_id, depot_id=depot_id, branch=branch) + # Depot file flags bitmap: + # u (user config), v (versioned user config), e (encrypted), r (read-only), h (hidden), + # x (executable), d (directory), c (custom executable), s (install script), l (symbolic link) + flags = ['u', 'v', 'e', 'r', 'h', 'x', 'd', 'c', 's', 'l'] + result = { + "id": manifest.id, + "app": manifest.app.id, + "depot": manifest.depot_id, + "name": manifest.name, + "count": len(manifest), + "size_raw": manifest.size_original, + "size_compressed": manifest.size_compressed, + "compressed": manifest.compressed, + "size": manifest.size_compressed if manifest.compressed else manifest.size_original, + "server": manifest.server.url.human_repr(), + "files": [ + { + "path": str(f), + "size": f.size, + "flags": [flag for i, flag in enumerate(flags) if int(f.flags.value) & (1 << i)], + "sha_name": serial(f.sha_filename), + "sha_content": serial(f.sha_content) + } for f in manifest.paths + ] + } + return result + + # < ------------ OTHER ------------> + + async def disconnect(self) -> None: + """Disconnect bot. The reason we don't use self.close() directly is because + that deactivates the auth tickets.""" + log("Disconnecting...") + if self.is_closed(): return + self._closed = True + await self.change_presence(apps=[]) # Disconnect from games + await self._state.handle_close() # Close TCP websocket + +client = Bot() +try: + if TOKEN: + log("Logging in with refresh token...") + client.run(refresh_token=TOKEN) + elif USERNAME and PASSWORD: + log("Logging in with username and password...") + client.run(USERNAME, PASSWORD) + else: + warn("No valid login credentials found, supply them via refresh token or username and password.") + raise SystemExit(ExitCode.NO_CREDENTIALS.value) +except* steam.gateway.ConnectionClosed: + pass +finally: + if not client.is_closed(): + asyncio.run(client.http.close()) # Close HTTP session \ No newline at end of file diff --git a/util/cp.sh b/util/cp.sh new file mode 100755 index 00000000..3565f40a --- /dev/null +++ b/util/cp.sh @@ -0,0 +1 @@ +cp /mnt/d/SSD/programs/inne/images/object_layers/$1.png images/object_layers/$1.png diff --git a/util/fonts/depixel.fnt b/util/fonts/depixel.fnt new file mode 100644 index 00000000..04429554 --- /dev/null +++ b/util/fonts/depixel.fnt @@ -0,0 +1,181 @@ +info face="DePixel" size=-11 bold=0 italic=0 charset="" unicode=1 stretchH=100 smooth=0 aa=1 padding=0,0,0,0 spacing=1,1 outline=0 +common lineHeight=16 base=13 scaleW=256 scaleH=256 pages=1 packed=0 alphaChnl=1 redChnl=0 greenChnl=0 blueChnl=0 +page id=0 file="depixel_0.tga" +chars count=177 +char id=-1 x=251 y=33 width=3 height=1 xoffset=-1 yoffset=15 xadvance=11 page=0 chnl=15 +char id=32 x=239 y=33 width=3 height=1 xoffset=-1 yoffset=15 xadvance=4 page=0 chnl=15 +char id=33 x=210 y=23 width=1 height=10 xoffset=1 yoffset=3 xadvance=4 page=0 chnl=15 +char id=34 x=207 y=34 width=4 height=2 xoffset=0 yoffset=3 xadvance=5 page=0 chnl=15 +char id=35 x=171 y=24 width=4 height=10 xoffset=0 yoffset=3 xadvance=4 page=0 chnl=15 +char id=36 x=49 y=0 width=6 height=12 xoffset=0 yoffset=2 xadvance=7 page=0 chnl=15 +char id=37 x=97 y=13 width=8 height=10 xoffset=0 yoffset=3 xadvance=11 page=0 chnl=15 +char id=38 x=96 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=39 x=220 y=33 width=1 height=2 xoffset=0 yoffset=3 xadvance=3 page=0 chnl=15 +char id=40 x=195 y=23 width=2 height=10 xoffset=0 yoffset=3 xadvance=4 page=0 chnl=15 +char id=41 x=207 y=23 width=2 height=10 xoffset=0 yoffset=3 xadvance=4 page=0 chnl=15 +char id=42 x=194 y=34 width=3 height=3 xoffset=0 yoffset=5 xadvance=5 page=0 chnl=15 +char id=43 x=214 y=23 width=9 height=9 xoffset=0 yoffset=4 xadvance=10 page=0 chnl=15 +char id=44 x=198 y=34 width=2 height=3 xoffset=0 yoffset=11 xadvance=4 page=0 chnl=15 +char id=45 x=234 y=33 width=4 height=1 xoffset=0 yoffset=9 xadvance=5 page=0 chnl=15 +char id=46 x=15 y=45 width=1 height=1 xoffset=0 yoffset=12 xadvance=3 page=0 chnl=15 +char id=47 x=151 y=24 width=4 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=48 x=131 y=24 width=4 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=49 x=192 y=23 width=2 height=10 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=50 x=126 y=24 width=4 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=51 x=136 y=24 width=4 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=52 x=102 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=53 x=108 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=54 x=84 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=55 x=120 y=24 width=5 height=10 xoffset=-1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=56 x=146 y=24 width=4 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=57 x=141 y=24 width=4 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=58 x=158 y=35 width=1 height=7 xoffset=1 yoffset=6 xadvance=4 page=0 chnl=15 +char id=59 x=253 y=23 width=2 height=8 xoffset=0 yoffset=6 xadvance=4 page=0 chnl=15 +char id=60 x=150 y=35 width=3 height=7 xoffset=0 yoffset=6 xadvance=5 page=0 chnl=15 +char id=61 x=176 y=35 width=7 height=3 xoffset=0 yoffset=7 xadvance=9 page=0 chnl=15 +char id=62 x=154 y=35 width=3 height=7 xoffset=0 yoffset=6 xadvance=5 page=0 chnl=15 +char id=63 x=200 y=12 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=64 x=25 y=0 width=9 height=12 xoffset=0 yoffset=3 xadvance=11 page=0 chnl=15 +char id=65 x=224 y=23 width=5 height=9 xoffset=0 yoffset=4 xadvance=7 page=0 chnl=15 +char id=66 x=158 y=13 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=67 x=130 y=13 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=68 x=221 y=12 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=69 x=249 y=12 width=5 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=70 x=0 y=26 width=5 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=71 x=179 y=12 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=72 x=207 y=12 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=73 x=212 y=23 width=1 height=10 xoffset=0 yoffset=3 xadvance=3 page=0 chnl=15 +char id=74 x=164 y=0 width=2 height=12 xoffset=-1 yoffset=3 xadvance=3 page=0 chnl=15 +char id=75 x=228 y=12 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=76 x=114 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=77 x=79 y=13 width=8 height=10 xoffset=0 yoffset=3 xadvance=10 page=0 chnl=15 +char id=78 x=137 y=13 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=79 x=144 y=13 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=80 x=151 y=13 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=81 x=183 y=0 width=6 height=11 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=82 x=165 y=13 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=83 x=172 y=13 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=84 x=106 y=13 width=7 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=85 x=186 y=12 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=86 x=6 y=26 width=5 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=87 x=46 y=13 width=10 height=10 xoffset=0 yoffset=3 xadvance=12 page=0 chnl=15 +char id=88 x=236 y=23 width=5 height=9 xoffset=0 yoffset=4 xadvance=7 page=0 chnl=15 +char id=89 x=230 y=23 width=5 height=9 xoffset=0 yoffset=4 xadvance=7 page=0 chnl=15 +char id=90 x=12 y=26 width=5 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=91 x=198 y=23 width=2 height=10 xoffset=0 yoffset=3 xadvance=4 page=0 chnl=15 +char id=92 x=248 y=23 width=4 height=9 xoffset=0 yoffset=4 xadvance=6 page=0 chnl=15 +char id=93 x=201 y=23 width=2 height=10 xoffset=0 yoffset=3 xadvance=4 page=0 chnl=15 +char id=94 x=170 y=35 width=5 height=4 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=95 x=222 y=33 width=5 height=1 xoffset=0 yoffset=14 xadvance=5 page=0 chnl=15 +char id=96 x=217 y=33 width=2 height=2 xoffset=1 yoffset=2 xadvance=0 page=0 chnl=15 +char id=97 x=125 y=35 width=4 height=7 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=98 x=48 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=99 x=130 y=35 width=4 height=7 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=100 x=18 y=26 width=5 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=101 x=135 y=35 width=4 height=7 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=102 x=156 y=24 width=4 height=10 xoffset=0 yoffset=3 xadvance=5 page=0 chnl=15 +char id=103 x=10 y=37 width=4 height=9 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=104 x=24 y=26 width=5 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=105 x=180 y=23 width=3 height=10 xoffset=0 yoffset=3 xadvance=5 page=0 chnl=15 +char id=106 x=167 y=0 width=2 height=12 xoffset=0 yoffset=3 xadvance=5 page=0 chnl=15 +char id=107 x=30 y=25 width=5 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=108 x=204 y=23 width=2 height=10 xoffset=0 yoffset=3 xadvance=5 page=0 chnl=15 +char id=109 x=31 y=36 width=8 height=7 xoffset=0 yoffset=6 xadvance=10 page=0 chnl=15 +char id=110 x=89 y=35 width=5 height=7 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=111 x=83 y=35 width=5 height=7 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=112 x=36 y=25 width=5 height=10 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=113 x=42 y=25 width=5 height=10 xoffset=0 yoffset=6 xadvance=7 page=0 chnl=15 +char id=114 x=77 y=35 width=5 height=7 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=115 x=140 y=35 width=4 height=7 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=116 x=176 y=24 width=3 height=10 xoffset=0 yoffset=3 xadvance=5 page=0 chnl=15 +char id=117 x=113 y=35 width=5 height=7 xoffset=0 yoffset=6 xadvance=7 page=0 chnl=15 +char id=118 x=107 y=35 width=5 height=7 xoffset=0 yoffset=6 xadvance=7 page=0 chnl=15 +char id=119 x=48 y=35 width=7 height=7 xoffset=0 yoffset=6 xadvance=10 page=0 chnl=15 +char id=120 x=101 y=35 width=5 height=7 xoffset=0 yoffset=6 xadvance=7 page=0 chnl=15 +char id=121 x=5 y=37 width=4 height=9 xoffset=0 yoffset=6 xadvance=7 page=0 chnl=15 +char id=122 x=145 y=35 width=4 height=7 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=123 x=184 y=23 width=3 height=10 xoffset=0 yoffset=3 xadvance=5 page=0 chnl=15 +char id=124 x=23 y=0 width=1 height=13 xoffset=0 yoffset=3 xadvance=3 page=0 chnl=15 +char id=125 x=188 y=23 width=3 height=10 xoffset=0 yoffset=3 xadvance=5 page=0 chnl=15 +char id=126 x=212 y=34 width=4 height=2 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=161 x=68 y=13 width=10 height=10 xoffset=0 yoffset=3 xadvance=11 page=0 chnl=15 +char id=162 x=6 y=0 width=5 height=13 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=165 x=243 y=33 width=3 height=1 xoffset=-1 yoffset=15 xadvance=3 page=0 chnl=15 +char id=167 x=247 y=33 width=3 height=1 xoffset=-1 yoffset=15 xadvance=5 page=0 chnl=15 +char id=168 x=228 y=33 width=5 height=1 xoffset=0 yoffset=3 xadvance=0 page=0 chnl=15 +char id=169 x=119 y=35 width=5 height=7 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=171 x=70 y=35 width=6 height=7 xoffset=0 yoffset=6 xadvance=9 page=0 chnl=15 +char id=174 x=63 y=35 width=6 height=7 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=181 x=15 y=37 width=15 height=7 xoffset=0 yoffset=6 xadvance=17 page=0 chnl=15 +char id=182 x=242 y=23 width=5 height=9 xoffset=1 yoffset=6 xadvance=7 page=0 chnl=15 +char id=187 x=56 y=35 width=6 height=7 xoffset=0 yoffset=6 xadvance=9 page=0 chnl=15 +char id=191 x=57 y=13 width=10 height=10 xoffset=0 yoffset=3 xadvance=11 page=0 chnl=15 +char id=192 x=147 y=0 width=5 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=193 x=117 y=0 width=5 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=194 x=111 y=0 width=5 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=195 x=153 y=0 width=5 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=196 x=227 y=0 width=5 height=11 xoffset=0 yoffset=2 xadvance=7 page=0 chnl=15 +char id=197 x=221 y=0 width=5 height=11 xoffset=0 yoffset=2 xadvance=7 page=0 chnl=15 +char id=198 x=88 y=13 width=8 height=10 xoffset=0 yoffset=3 xadvance=10 page=0 chnl=15 +char id=199 x=35 y=0 width=6 height=12 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=200 x=135 y=0 width=5 height=12 xoffset=0 yoffset=1 xadvance=6 page=0 chnl=15 +char id=201 x=141 y=0 width=5 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=202 x=123 y=0 width=5 height=12 xoffset=0 yoffset=1 xadvance=6 page=0 chnl=15 +char id=203 x=12 y=0 width=5 height=13 xoffset=0 yoffset=0 xadvance=6 page=0 chnl=15 +char id=204 x=173 y=0 width=2 height=12 xoffset=0 yoffset=1 xadvance=3 page=0 chnl=15 +char id=205 x=170 y=0 width=2 height=12 xoffset=-1 yoffset=1 xadvance=3 page=0 chnl=15 +char id=206 x=159 y=0 width=4 height=12 xoffset=-1 yoffset=1 xadvance=3 page=0 chnl=15 +char id=207 x=122 y=13 width=7 height=10 xoffset=0 yoffset=3 xadvance=9 page=0 chnl=15 +char id=208 x=114 y=13 width=7 height=10 xoffset=-1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=209 x=63 y=0 width=6 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=210 x=42 y=0 width=6 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=211 x=70 y=0 width=6 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=212 x=98 y=0 width=6 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=213 x=56 y=0 width=6 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=214 x=190 y=0 width=6 height=11 xoffset=0 yoffset=2 xadvance=7 page=0 chnl=15 +char id=216 x=193 y=12 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=217 x=91 y=0 width=6 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=218 x=84 y=0 width=6 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=219 x=77 y=0 width=6 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=220 x=176 y=0 width=6 height=11 xoffset=0 yoffset=2 xadvance=7 page=0 chnl=15 +char id=221 x=129 y=0 width=5 height=12 xoffset=0 yoffset=1 xadvance=7 page=0 chnl=15 +char id=222 x=214 y=12 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=223 x=54 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=224 x=0 y=14 width=4 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=225 x=251 y=0 width=4 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=226 x=5 y=14 width=4 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=227 x=10 y=14 width=4 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=228 x=60 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=229 x=15 y=14 width=4 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=230 x=40 y=36 width=7 height=7 xoffset=0 yoffset=6 xadvance=10 page=0 chnl=15 +char id=231 x=0 y=37 width=4 height=9 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=232 x=20 y=14 width=4 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=233 x=25 y=13 width=4 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=234 x=30 y=13 width=4 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=235 x=66 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=236 x=40 y=13 width=2 height=11 xoffset=0 yoffset=2 xadvance=5 page=0 chnl=15 +char id=237 x=43 y=13 width=2 height=11 xoffset=0 yoffset=2 xadvance=5 page=0 chnl=15 +char id=238 x=35 y=13 width=4 height=11 xoffset=0 yoffset=2 xadvance=5 page=0 chnl=15 +char id=239 x=161 y=24 width=4 height=10 xoffset=-1 yoffset=3 xadvance=5 page=0 chnl=15 +char id=240 x=72 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=241 x=233 y=0 width=5 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=242 x=215 y=0 width=5 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=243 x=209 y=0 width=5 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=244 x=166 y=24 width=4 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=245 x=203 y=0 width=5 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=246 x=78 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=248 x=95 y=35 width=5 height=7 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=249 x=197 y=0 width=5 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=250 x=245 y=0 width=5 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=251 x=239 y=0 width=5 height=11 xoffset=0 yoffset=2 xadvance=6 page=0 chnl=15 +char id=252 x=90 y=24 width=5 height=10 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=253 x=18 y=0 width=4 height=13 xoffset=0 yoffset=2 xadvance=7 page=0 chnl=15 +char id=254 x=0 y=0 width=5 height=13 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=255 x=105 y=0 width=5 height=12 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=8216 x=201 y=34 width=2 height=3 xoffset=0 yoffset=5 xadvance=4 page=0 chnl=15 +char id=8217 x=204 y=34 width=2 height=3 xoffset=0 yoffset=5 xadvance=4 page=0 chnl=15 +char id=8220 x=184 y=34 width=4 height=3 xoffset=0 yoffset=5 xadvance=6 page=0 chnl=15 +char id=8221 x=189 y=34 width=4 height=3 xoffset=0 yoffset=5 xadvance=6 page=0 chnl=15 +char id=8482 x=160 y=35 width=9 height=5 xoffset=0 yoffset=3 xadvance=11 page=0 chnl=15 +char id=64257 x=235 y=12 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=64258 x=242 y=12 width=6 height=10 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 diff --git a/util/fonts/depixel_0.tga b/util/fonts/depixel_0.tga new file mode 100644 index 00000000..0df3aad6 Binary files /dev/null and b/util/fonts/depixel_0.tga differ diff --git a/util/fonts/retro.fnt b/util/fonts/retro.fnt new file mode 100755 index 00000000..504bb045 --- /dev/null +++ b/util/fonts/retro.fnt @@ -0,0 +1,320 @@ +info face="Retro Gaming" size=14 bold=0 italic=0 charset="" unicode=1 stretchH=100 smooth=0 aa=1 padding=0,0,0,0 spacing=1,1 outline=0 +common lineHeight=14 base=11 scaleW=256 scaleH=256 pages=1 packed=0 alphaChnl=1 redChnl=0 greenChnl=0 blueChnl=0 +page id=0 file="retro_0.tga" +chars count=316 +char id=32 x=128 y=66 width=3 height=1 xoffset=-1 yoffset=13 xadvance=4 page=0 chnl=15 +char id=33 x=39 y=69 width=2 height=8 xoffset=1 yoffset=3 xadvance=3 page=0 chnl=15 +char id=34 x=148 y=80 width=5 height=3 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=35 x=11 y=25 width=10 height=8 xoffset=1 yoffset=3 xadvance=11 page=0 chnl=15 +char id=36 x=3 y=0 width=7 height=12 xoffset=1 yoffset=1 xadvance=8 page=0 chnl=15 +char id=37 x=245 y=21 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=38 x=176 y=21 width=8 height=8 xoffset=1 yoffset=3 xadvance=9 page=0 chnl=15 +char id=39 x=167 y=80 width=2 height=3 xoffset=1 yoffset=3 xadvance=3 page=0 chnl=15 +char id=40 x=251 y=57 width=4 height=8 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=41 x=0 y=71 width=4 height=8 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=42 x=20 y=79 width=8 height=5 xoffset=1 yoffset=4 xadvance=9 page=0 chnl=15 +char id=43 x=171 y=66 width=6 height=6 xoffset=1 yoffset=4 xadvance=7 page=0 chnl=15 +char id=44 x=163 y=80 width=3 height=3 xoffset=0 yoffset=9 xadvance=3 page=0 chnl=15 +char id=45 x=176 y=79 width=4 height=2 xoffset=1 yoffset=6 xadvance=6 page=0 chnl=15 +char id=46 x=181 y=79 width=2 height=2 xoffset=1 yoffset=9 xadvance=3 page=0 chnl=15 +char id=47 x=64 y=32 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=48 x=72 y=32 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=49 x=10 y=71 width=4 height=8 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=50 x=88 y=32 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=51 x=96 y=32 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=52 x=104 y=32 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=53 x=112 y=32 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=54 x=120 y=32 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=55 x=128 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=56 x=136 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=57 x=144 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=58 x=190 y=66 width=2 height=6 xoffset=1 yoffset=5 xadvance=3 page=0 chnl=15 +char id=59 x=159 y=66 width=3 height=7 xoffset=0 yoffset=5 xadvance=3 page=0 chnl=15 +char id=60 x=147 y=66 width=5 height=7 xoffset=1 yoffset=4 xadvance=6 page=0 chnl=15 +char id=61 x=142 y=80 width=5 height=3 xoffset=1 yoffset=6 xadvance=6 page=0 chnl=15 +char id=62 x=153 y=66 width=5 height=7 xoffset=1 yoffset=4 xadvance=6 page=0 chnl=15 +char id=63 x=176 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=64 x=184 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=65 x=192 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=66 x=200 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=67 x=208 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=68 x=216 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=69 x=224 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=70 x=232 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=71 x=221 y=21 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=72 x=240 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=73 x=165 y=57 width=6 height=8 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=74 x=248 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=75 x=0 y=44 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=76 x=172 y=57 width=6 height=8 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=77 x=8 y=44 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=78 x=16 y=43 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=79 x=24 y=42 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=80 x=32 y=42 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=81 x=40 y=42 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=82 x=48 y=41 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=83 x=56 y=41 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=84 x=64 y=41 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=85 x=72 y=41 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=86 x=80 y=41 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=87 x=203 y=21 width=8 height=8 xoffset=1 yoffset=3 xadvance=9 page=0 chnl=15 +char id=88 x=88 y=41 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=89 x=96 y=41 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=90 x=104 y=41 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=91 x=20 y=70 width=4 height=8 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=92 x=112 y=41 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=93 x=246 y=57 width=4 height=8 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=94 x=113 y=82 width=7 height=3 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=95 x=122 y=30 width=5 height=1 xoffset=1 yoffset=10 xadvance=6 page=0 chnl=15 +char id=96 x=154 y=80 width=4 height=3 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=97 x=224 y=72 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=98 x=120 y=41 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=99 x=208 y=72 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=100 x=128 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=101 x=192 y=73 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=102 x=240 y=57 width=5 height=8 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=103 x=136 y=39 width=7 height=8 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=104 x=144 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=105 x=33 y=69 width=2 height=8 xoffset=1 yoffset=3 xadvance=3 page=0 chnl=15 +char id=106 x=116 y=12 width=5 height=9 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=107 x=200 y=57 width=6 height=8 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=108 x=29 y=69 width=3 height=8 xoffset=1 yoffset=3 xadvance=4 page=0 chnl=15 +char id=109 x=40 y=84 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=110 x=88 y=76 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=111 x=160 y=74 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=112 x=152 y=39 width=7 height=8 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=113 x=160 y=39 width=7 height=8 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=114 x=56 y=82 width=6 height=5 xoffset=1 yoffset=6 xadvance=7 page=0 chnl=15 +char id=115 x=152 y=74 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=116 x=234 y=57 width=5 height=8 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=117 x=144 y=74 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=118 x=136 y=74 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=119 x=120 y=76 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=120 x=104 y=76 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=121 x=168 y=39 width=7 height=8 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=122 x=96 y=76 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=123 x=15 y=71 width=4 height=8 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=124 x=253 y=0 width=2 height=10 xoffset=1 yoffset=3 xadvance=3 page=0 chnl=15 +char id=125 x=5 y=71 width=4 height=8 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=126 x=121 y=82 width=6 height=3 xoffset=1 yoffset=5 xadvance=7 page=0 chnl=15 +char id=161 x=36 y=69 width=2 height=8 xoffset=1 yoffset=3 xadvance=3 page=0 chnl=15 +char id=162 x=126 y=68 width=7 height=7 xoffset=1 yoffset=5 xadvance=8 page=0 chnl=15 +char id=163 x=176 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=164 x=163 y=66 width=7 height=6 xoffset=1 yoffset=4 xadvance=8 page=0 chnl=15 +char id=165 x=184 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=166 x=0 y=0 width=2 height=13 xoffset=1 yoffset=1 xadvance=3 page=0 chnl=15 +char id=167 x=192 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=168 x=170 y=79 width=5 height=2 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=169 x=52 y=23 width=9 height=8 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=170 x=185 y=66 width=4 height=6 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=171 x=52 y=68 width=9 height=7 xoffset=1 yoffset=4 xadvance=10 page=0 chnl=15 +char id=174 x=178 y=66 width=6 height=6 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=177 x=207 y=57 width=6 height=8 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=181 x=122 y=21 width=8 height=8 xoffset=0 yoffset=6 xadvance=8 page=0 chnl=15 +char id=182 x=200 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=187 x=62 y=68 width=9 height=7 xoffset=1 yoffset=4 xadvance=10 page=0 chnl=15 +char id=191 x=208 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=192 x=53 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=193 x=61 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=194 x=69 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=195 x=77 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=196 x=101 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=197 x=117 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=198 x=241 y=12 width=10 height=8 xoffset=1 yoffset=3 xadvance=11 page=0 chnl=15 +char id=199 x=101 y=12 width=7 height=10 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=200 x=125 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=201 x=133 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=202 x=173 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=203 x=189 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=204 x=8 y=13 width=6 height=11 xoffset=1 yoffset=0 xadvance=7 page=0 chnl=15 +char id=205 x=15 y=12 width=6 height=11 xoffset=1 yoffset=0 xadvance=7 page=0 chnl=15 +char id=206 x=29 y=12 width=6 height=11 xoffset=1 yoffset=0 xadvance=7 page=0 chnl=15 +char id=207 x=22 y=12 width=6 height=11 xoffset=1 yoffset=0 xadvance=7 page=0 chnl=15 +char id=208 x=212 y=21 width=8 height=8 xoffset=1 yoffset=3 xadvance=9 page=0 chnl=15 +char id=209 x=197 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=210 x=205 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=211 x=213 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=212 x=221 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=213 x=237 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=214 x=245 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=215 x=91 y=82 width=5 height=5 xoffset=1 yoffset=4 xadvance=6 page=0 chnl=15 +char id=216 x=216 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=217 x=37 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=218 x=109 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=219 x=21 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=220 x=157 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=221 x=93 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=222 x=224 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=223 x=232 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=224 x=240 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=225 x=248 y=39 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=226 x=0 y=53 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=227 x=8 y=53 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=228 x=16 y=52 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=229 x=24 y=51 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=230 x=193 y=66 width=13 height=5 xoffset=1 yoffset=6 xadvance=14 page=0 chnl=15 +char id=231 x=118 y=68 width=7 height=7 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=232 x=32 y=51 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=233 x=40 y=51 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=234 x=48 y=50 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=235 x=56 y=50 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=236 x=252 y=12 width=3 height=8 xoffset=0 yoffset=3 xadvance=3 page=0 chnl=15 +char id=237 x=25 y=69 width=3 height=8 xoffset=1 yoffset=3 xadvance=4 page=0 chnl=15 +char id=238 x=144 y=57 width=6 height=8 xoffset=-1 yoffset=3 xadvance=5 page=0 chnl=15 +char id=239 x=179 y=57 width=6 height=8 xoffset=-1 yoffset=3 xadvance=5 page=0 chnl=15 +char id=240 x=167 y=21 width=8 height=8 xoffset=1 yoffset=3 xadvance=9 page=0 chnl=15 +char id=241 x=72 y=50 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=242 x=80 y=50 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=243 x=88 y=50 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=244 x=96 y=50 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=245 x=104 y=50 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=246 x=112 y=50 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=247 x=63 y=82 width=6 height=5 xoffset=1 yoffset=5 xadvance=7 page=0 chnl=15 +char id=248 x=112 y=76 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=249 x=120 y=50 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=250 x=128 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=251 x=136 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=252 x=144 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=253 x=45 y=0 width=7 height=11 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=254 x=29 y=0 width=7 height=11 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=255 x=181 y=0 width=7 height=11 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=376 x=229 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=913 x=152 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=914 x=160 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=915 x=193 y=57 width=6 height=8 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=916 x=219 y=12 width=10 height=8 xoffset=1 yoffset=3 xadvance=11 page=0 chnl=15 +char id=917 x=168 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=918 x=176 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=919 x=184 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=920 x=192 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=921 x=158 y=57 width=6 height=8 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=922 x=200 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=923 x=230 y=12 width=10 height=8 xoffset=1 yoffset=3 xadvance=11 page=0 chnl=15 +char id=924 x=208 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=925 x=216 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=926 x=224 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=927 x=232 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=928 x=22 y=24 width=9 height=8 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=929 x=240 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=931 x=248 y=48 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=932 x=0 y=62 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=933 x=42 y=23 width=9 height=8 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=934 x=102 y=23 width=9 height=8 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=935 x=8 y=62 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=936 x=172 y=12 width=11 height=8 xoffset=1 yoffset=3 xadvance=12 page=0 chnl=15 +char id=937 x=160 y=12 width=11 height=8 xoffset=1 yoffset=3 xadvance=12 page=0 chnl=15 +char id=945 x=128 y=76 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=946 x=165 y=0 width=7 height=11 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1025 x=149 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=1028 x=16 y=61 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1030 x=221 y=57 width=6 height=8 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=1031 x=141 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=1033 x=122 y=12 width=12 height=8 xoffset=1 yoffset=3 xadvance=13 page=0 chnl=15 +char id=1034 x=148 y=12 width=11 height=8 xoffset=1 yoffset=3 xadvance=12 page=0 chnl=15 +char id=1040 x=24 y=60 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1041 x=32 y=60 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1042 x=40 y=60 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1043 x=186 y=57 width=6 height=8 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=1044 x=66 y=12 width=8 height=10 xoffset=1 yoffset=3 xadvance=9 page=0 chnl=15 +char id=1045 x=48 y=59 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1046 x=158 y=21 width=8 height=8 xoffset=1 yoffset=3 xadvance=9 page=0 chnl=15 +char id=1047 x=56 y=59 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1048 x=64 y=59 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1049 x=85 y=0 width=7 height=11 xoffset=1 yoffset=0 xadvance=8 page=0 chnl=15 +char id=1050 x=72 y=59 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1051 x=80 y=59 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1052 x=88 y=59 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1053 x=96 y=59 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1054 x=104 y=59 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1055 x=112 y=59 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1056 x=120 y=59 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1057 x=128 y=57 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1058 x=136 y=57 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1059 x=229 y=21 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1060 x=0 y=26 width=10 height=8 xoffset=1 yoffset=3 xadvance=11 page=0 chnl=15 +char id=1061 x=237 y=21 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1062 x=57 y=12 width=8 height=10 xoffset=1 yoffset=3 xadvance=9 page=0 chnl=15 +char id=1063 x=0 y=35 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1064 x=82 y=23 width=9 height=8 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=1065 x=36 y=12 width=10 height=10 xoffset=1 yoffset=3 xadvance=11 page=0 chnl=15 +char id=1066 x=62 y=23 width=9 height=8 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=1067 x=208 y=12 width=10 height=8 xoffset=1 yoffset=3 xadvance=11 page=0 chnl=15 +char id=1068 x=64 y=50 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1069 x=8 y=35 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1070 x=196 y=12 width=11 height=8 xoffset=1 yoffset=3 xadvance=12 page=0 chnl=15 +char id=1071 x=16 y=34 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1072 x=168 y=73 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1073 x=24 y=33 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1074 x=176 y=73 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1075 x=97 y=82 width=5 height=5 xoffset=1 yoffset=6 xadvance=6 page=0 chnl=15 +char id=1076 x=82 y=68 width=8 height=7 xoffset=1 yoffset=6 xadvance=9 page=0 chnl=15 +char id=1077 x=200 y=72 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1078 x=29 y=78 width=8 height=5 xoffset=1 yoffset=6 xadvance=9 page=0 chnl=15 +char id=1079 x=232 y=72 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1080 x=240 y=72 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1081 x=32 y=33 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1082 x=70 y=82 width=6 height=5 xoffset=1 yoffset=6 xadvance=7 page=0 chnl=15 +char id=1083 x=248 y=72 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1084 x=0 y=86 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1085 x=8 y=86 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1086 x=16 y=86 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1087 x=24 y=85 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1088 x=40 y=33 width=7 height=8 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1089 x=184 y=73 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1090 x=84 y=82 width=6 height=5 xoffset=1 yoffset=6 xadvance=7 page=0 chnl=15 +char id=1091 x=48 y=32 width=7 height=8 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1092 x=11 y=0 width=9 height=11 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=1093 x=56 y=76 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1094 x=91 y=68 width=8 height=7 xoffset=1 yoffset=6 xadvance=9 page=0 chnl=15 +char id=1095 x=64 y=76 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1096 x=38 y=78 width=8 height=5 xoffset=1 yoffset=6 xadvance=9 page=0 chnl=15 +char id=1097 x=42 y=69 width=9 height=7 xoffset=1 yoffset=6 xadvance=10 page=0 chnl=15 +char id=1098 x=242 y=66 width=9 height=5 xoffset=1 yoffset=6 xadvance=10 page=0 chnl=15 +char id=1099 x=0 y=80 width=9 height=5 xoffset=1 yoffset=6 xadvance=10 page=0 chnl=15 +char id=1100 x=72 y=76 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1101 x=80 y=76 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1102 x=231 y=66 width=10 height=5 xoffset=1 yoffset=6 xadvance=11 page=0 chnl=15 +char id=1103 x=32 y=84 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1104 x=56 y=32 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1105 x=80 y=32 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1107 x=228 y=57 width=5 height=8 xoffset=1 yoffset=3 xadvance=6 page=0 chnl=15 +char id=1108 x=48 y=83 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1110 x=253 y=21 width=2 height=8 xoffset=1 yoffset=3 xadvance=3 page=0 chnl=15 +char id=1111 x=151 y=57 width=6 height=8 xoffset=-1 yoffset=3 xadvance=5 page=0 chnl=15 +char id=1113 x=207 y=66 width=11 height=5 xoffset=1 yoffset=6 xadvance=12 page=0 chnl=15 +char id=1114 x=219 y=66 width=11 height=5 xoffset=1 yoffset=6 xadvance=12 page=0 chnl=15 +char id=1115 x=185 y=21 width=8 height=8 xoffset=0 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1116 x=214 y=57 width=6 height=8 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=1117 x=152 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1118 x=0 y=14 width=7 height=11 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1168 x=109 y=12 width=6 height=10 xoffset=1 yoffset=1 xadvance=7 page=0 chnl=15 +char id=1169 x=141 y=66 width=5 height=7 xoffset=1 yoffset=4 xadvance=6 page=0 chnl=15 +char id=1170 x=160 y=30 width=7 height=8 xoffset=0 yoffset=3 xadvance=7 page=0 chnl=15 +char id=1171 x=77 y=82 width=6 height=5 xoffset=0 yoffset=6 xadvance=6 page=0 chnl=15 +char id=1174 x=47 y=12 width=9 height=10 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=1175 x=72 y=68 width=9 height=7 xoffset=1 yoffset=6 xadvance=10 page=0 chnl=15 +char id=1178 x=75 y=12 width=8 height=10 xoffset=1 yoffset=3 xadvance=9 page=0 chnl=15 +char id=1179 x=100 y=68 width=8 height=7 xoffset=1 yoffset=6 xadvance=9 page=0 chnl=15 +char id=1180 x=149 y=21 width=8 height=8 xoffset=1 yoffset=3 xadvance=9 page=0 chnl=15 +char id=1181 x=216 y=72 width=7 height=5 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=15 +char id=1182 x=140 y=21 width=8 height=8 xoffset=0 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1183 x=131 y=21 width=8 height=8 xoffset=0 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1184 x=32 y=24 width=9 height=8 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=1185 x=47 y=77 width=8 height=5 xoffset=1 yoffset=6 xadvance=9 page=0 chnl=15 +char id=1186 x=84 y=12 width=8 height=10 xoffset=1 yoffset=3 xadvance=9 page=0 chnl=15 +char id=1187 x=109 y=68 width=8 height=7 xoffset=1 yoffset=6 xadvance=9 page=0 chnl=15 +char id=1188 x=92 y=23 width=9 height=8 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=1189 x=10 y=80 width=9 height=5 xoffset=1 yoffset=6 xadvance=10 page=0 chnl=15 +char id=1196 x=93 y=12 width=7 height=10 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=1197 x=134 y=66 width=6 height=7 xoffset=1 yoffset=6 xadvance=7 page=0 chnl=15 +char id=8216 x=252 y=66 width=3 height=3 xoffset=1 yoffset=3 xadvance=4 page=0 chnl=15 +char id=8217 x=159 y=80 width=3 height=3 xoffset=0 yoffset=3 xadvance=3 page=0 chnl=15 +char id=8220 x=128 y=82 width=6 height=3 xoffset=1 yoffset=3 xadvance=7 page=0 chnl=15 +char id=8221 x=135 y=82 width=6 height=3 xoffset=0 yoffset=3 xadvance=6 page=0 chnl=15 +char id=8364 x=112 y=23 width=9 height=8 xoffset=0 yoffset=3 xadvance=9 page=0 chnl=15 +char id=8482 x=103 y=82 width=9 height=4 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=64256 x=72 y=23 width=9 height=8 xoffset=1 yoffset=3 xadvance=10 page=0 chnl=15 +char id=64257 x=168 y=30 width=7 height=8 xoffset=1 yoffset=3 xadvance=8 page=0 chnl=15 +char id=64258 x=194 y=21 width=8 height=8 xoffset=1 yoffset=3 xadvance=9 page=0 chnl=15 +char id=64259 x=184 y=12 width=11 height=8 xoffset=1 yoffset=3 xadvance=12 page=0 chnl=15 +char id=64260 x=135 y=12 width=12 height=8 xoffset=1 yoffset=3 xadvance=13 page=0 chnl=15 diff --git a/util/fonts/retro_0.tga b/util/fonts/retro_0.tga new file mode 100755 index 00000000..58e47c24 Binary files /dev/null and b/util/fonts/retro_0.tga differ diff --git a/util/fonts/sys/Sys-Bold.otf b/util/fonts/sys/Sys-Bold.otf new file mode 100755 index 00000000..bdb3bc64 Binary files /dev/null and b/util/fonts/sys/Sys-Bold.otf differ diff --git a/util/fonts/sys/Sys-BoldItalic.otf b/util/fonts/sys/Sys-BoldItalic.otf new file mode 100755 index 00000000..767361a9 Binary files /dev/null and b/util/fonts/sys/Sys-BoldItalic.otf differ diff --git a/util/fonts/sys/Sys-Italic.otf b/util/fonts/sys/Sys-Italic.otf new file mode 100755 index 00000000..1131cf58 Binary files /dev/null and b/util/fonts/sys/Sys-Italic.otf differ diff --git a/util/fonts/sys/Sys.otf b/util/fonts/sys/Sys.otf new file mode 100755 index 00000000..9100b0ff Binary files /dev/null and b/util/fonts/sys/Sys.otf differ diff --git a/util/fonts/sys/Sys.ttf b/util/fonts/sys/Sys.ttf new file mode 100755 index 00000000..b5ab573b Binary files /dev/null and b/util/fonts/sys/Sys.ttf differ diff --git a/util/generatePalette.rb b/util/generatePalette.rb new file mode 100755 index 00000000..53918daa --- /dev/null +++ b/util/generatePalette.rb @@ -0,0 +1,191 @@ +# This was used to generate the palette image +# It's not a complete TGA parser, just the bare minimum required + +$boot_time = Time.now + +require 'chunky_png' +require 'rbconfig' +require 'stringio' + +# TGA properties +TGA_HEADER_SIZE = 18 +TGA_HEADER_FORMAT = 'C3S<2CS<4C2' +TGAHeader = Struct.new( + :id_length, :colormap_type, :image_type, # (3 bytes) + :cm_origin, :cm_length, :cm_depth, # Color map specification (5 bytes) + :origin_x, :origin_y, :width, :height, :depth, :desc # Image specification (10 bytes) +) + +FILE_NAMES = [ + # Main map colors used for screenshots + "background", "ninja", "entityMine", "entityGold", + "entityDoorExit", "entityDoorExitSwitch", "entityDoorRegular", "entityDoorLocked", + "entityDoorTrap", "entityLaunchPad", "entityOneWayPlatform", "entityDroneChaingun", + "entityDroneLaser", "entityDroneZap", "entityDroneChaser", "entityFloorGuard", + "entityBounceBlock", "entityRocket", "entityTurret", "entityThwomp", + "entityEvilNinja", "entityDualLaser", "entityBoostPad", "entityBat", + "entityEyeBat", "entityShoveThwomp", + + # Colors for other parts of the gameplay + "headbands", "explosions", "timeBar", "timeBarRace", + "fxNinja", "fxDroneZap", "fxFloorguardZap", + + # Interface colors + "menu", "editor" +] + +# The 129 official palette names. The order of the first 123 is important, they +# are baked in the game's files and this can be regarded as their ID, useful +# for indexing into the nprofile. The last 6 are the TEN++ ones, they were added +# as regular custom palettes so their index is irrelevant. The first 62 come from +# Pre-UE, and the next 61 from UE, thus why each half is alphabetically sorted. +PALETTE_NAMES = [ + 'BASIC', 'F7200', 'acid', 'airline', + 'birthday cake', 'blueprint', 'bordeaux', 'chemical', + 'chococherry', 'classic', 'clean', 'console', + 'disassembly', 'dorado', 'dusk', 'epaper', + 'epaper invert', 'evening', 'galactic', 'gothmode', + 'holosphere', 'hot', 'infographic', 'invert', + 'kicks', 'lightcycle', 'm', 'metoro', + 'midnight', 'minus', 'mir', 'mono', + 'moonbase', 'neptune', 'oceanographer', 'okinami', + 'orbit', 'pale', 'papier', 'papier invert', + 'party', 'pinku', 'plus', 'poseidon', + 'pulse', 'quench', 'replicant', 'retro', + 'shift', 'shock', 'simulator', 'solarized dark', + 'solarized light', 'supernavy', 'toxin', 'vasquez', + 'virtual', 'vivid', 'wizard', 'yeti', + 'pumpkin', 'witchy', 'argon', 'autumn', + 'berry', 'bloodmoon', 'brink', 'cacao', + 'champagne', 'concrete', 'cowboy', 'dagobah', + 'debugger', 'delicate', 'desert world', 'elephant', + 'florist', 'formal', 'gatecrasher', 'grapefrukt', + 'grappa', 'gunmetal', 'hazard', 'heirloom', + 'hope', 'hyperspace', 'ice world', 'incorporated', + 'jaune', 'juicy', 'lab', 'lava world', + 'lemonade', 'lichen', 'line', 'machine', + 'mustard', 'mute', 'nemk', 'neutrality', + 'noctis', 'petal', 'PICO-8', 'porphyrous', + 'QDUST', 'regal', 'rust', 'sakura', + 'sinister', 'starfighter', 'sunset', 'synergy', + 'talisman', 'toothpaste', 'TR-808', 'tycho', + 'vectrex', 'vintage', 'void', 'waka', + 'wyvern', 'xenon', 'powder', 'anniversary', + 'burple', 'classy', 'SNKRX', 'ten', + 'tropical' +] + +# Find palette directory +PATHS = { + 'windows' => "C:/Program Files (x86)/Steam/steamapps/common/N++/NPP/Palettes", + 'linux' => "#{Dir.home}/.steam/steam/steamapps/common/N++/NPP/Palettes" +} +SYS = RbConfig::CONFIG['host_os'] =~ /linux/i ? 'linux' : 'windows' +DIR = PATHS[SYS] + +# For the final name export +COLUMNS = 4 + +def log(str, ln = true) = print "\e[2K\r[%.3f] %s%s" % [Time.now - $boot_time, str, ln ? "\n" : ''] + +# Parse palettes and count total colors to define output image +log "Generating palette image..." +current = Dir.entries(DIR).to_set +missing = PALETTE_NAMES.select{ |palette| !current.include?(palette) } +abort("Missing palettes: #{missing.join(', ')}") if missing.size > 0 +colors = FILE_NAMES.inject(0){ |total, name| + file = File.binread("#{DIR}/#{PALETTE_NAMES.first}/#{name}.tga") + total + file[12, 2].unpack('S<')[0] / 64 +} +output = ChunkyPNG::Image.new(colors, PALETTE_NAMES.size + 1, ChunkyPNG::Color::WHITE) +log "Palettes found: #{PALETTE_NAMES.size}" +log "Colors found: #{colors}" + +# Parse all colors and fill output image +count = PALETTE_NAMES.count +PALETTE_NAMES.each_with_index{ |palette, y| + log "Parsing palette [#{y + 1} / #{count}] #{palette}", false + x = -1 + + FILE_NAMES.each{ |name| + file = File.open("#{DIR}/#{palette}/#{name}.tga", 'rb') + + # Parse TGA properties + tga = TGAHeader.new(*file.read(TGA_HEADER_SIZE).unpack(TGA_HEADER_FORMAT)) + total_pixels = tga.width * tga.height + colors, pixel_size = tga.width / 64, tga.depth / 8 + flip_x, flip_y, interlace = tga.desc[4] == 1, tga.desc[5] == 1, tga.desc[6, 2] + + # Sanity checks + abort("Only RGB images are supported.") if ![2, 10].include?(tga.image_type) + abort("Color-mapped images not supported.") if tga.colormap_type != 0 + abort("Interlaced images not supported.") if interlace > 0 + abort("Pixel depth isn't a multiple of 8.") if tga.depth % 8 != 0 + abort("Pixel depth cannot hold true color.") if tga.depth < 24 + abort("Colormap depth isn't a multiple of 8.") if tga.cm_depth % 8 != 0 + + # Read pixel data + file.seek(tga.id_length + tga.cm_length * tga.cm_depth / 8, IO::SEEK_CUR) + pixel_data = file.read(total_pixels * pixel_size) + file.close + + # Decode RLE + if tga.image_type == 10 + buffer = StringIO.new(pixel_data) + pixels = ''.b + pixel_count = 0 + loop do + header_byte = buffer.read(1) + break unless header_byte + packet_header = header_byte.unpack1('C') + packet_size = (packet_header & 0x7F) + 1 + if (packet_header & 0x80) == 0 # Raw packet + pixels << buffer.read(packet_size * pixel_size) + else # RLE packet + pixels << buffer.read(pixel_size) * packet_size + end + pixel_count += packet_size + break if pixel_count >= total_pixels || buffer.eof? + end + pixel_data = pixels + end + abort("Corrupt pixel data") if pixel_data.size != total_pixels * pixel_size + + # Pixel data may be flipped + if flip_x + pixel_data = pixel_data.unpack("a#{pixel_size}" * total_pixels).each_slice(tga.width).map{ |row| row.reverse.join } + pixel_data.reverse! if flip_y + pixel_data = pixel_data.join + elsif flip_y + pixel_data = pixel_data.unpack("a#{pixel_size * tga.width}" * tga.height).reverse.join + end + + # The game samples the "middle" pixel (32, 32) of each 64x64 block FROM THE BOTTOM + offset = 32 * pixel_size * tga.width + 32 * pixel_size + step = 64 * pixel_size + colors.times.each{ |i| + b, g, r = pixel_data.unpack("C3", offset: offset + step * i) + output[x += 1, y] = ChunkyPNG::Color.rgb(r, g, b) + } + } +} +puts + +# Export master palette image +output.save('palette.png', :fast_rgb) +log "Exported palette image" + +# Print names in Ruby array format +PALETTE_NAMES << 'custom' +widths = COLUMNS.times.map{ |n| + PALETTE_NAMES.select.with_index{ |palette, i| i % COLUMNS == n }.map(&:length).max +} +puts <<-ARY + THEMES = [ +#{ + PALETTE_NAMES.each_slice(COLUMNS).map{ |row| + ' ' + row.map.with_index{ |palette, i| "'#{palette}',".ljust(widths[i] + 3, ' ') }.join(' ') + }.join("\n")[..-2] +} + ] +ARY diff --git a/util/hc_screenies.rb b/util/hc_screenies.rb new file mode 100644 index 00000000..788557ec --- /dev/null +++ b/util/hc_screenies.rb @@ -0,0 +1,25 @@ +require 'rmagick' +Dir.chdir('../screenshots') +files = Dir.entries(Dir.pwd).select{ |f| File.file?(f) }.map{ |f| f[0..-5] }.sort + +def ids(tab, offset, n) + ret = (0..n - 1).to_a.map{ |s| + tab + "-" + s.to_s.rjust(2,"0") + }.each_with_index.map{ |l, i| [offset + i, l] }.to_h +end + +[['SI', 0, 5], ['S', 24, 20], ['SL', 48, 20], ['SU', 96, 20]].each{ |s| + ids(s[0],s[1],s[2]).each{ |story| + images = files.select{ |f| + parts = f.split('-') + parts.size == 3 && parts[1] != 'X' && + (parts[0] + '-' + parts[2]) == story[1] + } + list = Magick::ImageList.new + images.each{ |f| list.push(Magick::Image.read(f + '.jpg').first) } + list.append(true).write(story[1] + '.jpg') + #print(story[1] + ": ") + #images.each{ |f| print(f + " ") } + #print("\n") + } +} diff --git a/util/install_patch.rb b/util/install_patch.rb new file mode 100755 index 00000000..1024aa15 --- /dev/null +++ b/util/install_patch.rb @@ -0,0 +1,56 @@ +require 'rbconfig' + +TEST = false +TARGET = "https://dojo.nplusplus.ninja" +HOST_TEST = "127.0.0.1" +HOST = "45.32.150.168" +PORT = 8126 + +def find_lib + paths = { + 'windows' => "C:/Program Files (x86)/Steam/steamapps/common/N++/npp.dll", + 'linux' => "#{Dir.home}/.steam/steam/steamapps/common/N++/lib64/libnpp.so" + } + sys = RbConfig::CONFIG['host_os'] =~ /linux/i ? 'linux' : 'windows' + paths[sys] +end + +def patch + path = find_lib + new_lib = IO.binread(find_lib).gsub!(TARGET, PROXY) + new_lib.nil? ? puts("Didn't find URL") : IO.binwrite(find_lib, new_lib) +end + +def depatch + path = find_lib + new_lib = IO.binread(find_lib).gsub!(PROXY, TARGET) + new_lib.nil? ? puts("Didn't find URL") : IO.binwrite(find_lib, new_lib) +end + +case ARGV.size +when 0, 1 + puts "Missing args" +when 2, 3 + pack = ARGV[1] + port = (ARGV[2] || PORT).to_i + if port <= 1024 || port >= 65536 + puts "Incorrect port" + exit + end + proxy = "#{TEST ? HOST_TEST : HOST}:#{port}/#{pack}" + if proxy.length > TARGET.length + puts "Mappack name is too long" + exit + end + PROXY = proxy.ljust(TARGET.length, "\x00") + case ARGV[0] + when 'p' + patch + when 'd' + depatch + else + puts "Incorrect option" + end +else + puts "Too many args" +end diff --git a/util/mappacker/build.bat b/util/mappacker/build.bat new file mode 100755 index 00000000..594cb0a6 --- /dev/null +++ b/util/mappacker/build.bat @@ -0,0 +1,2 @@ +:: ocra --no-autoload --no-enc --windows --dll ruby_builtin_dlls\libssp-0.dll --dll ruby_builtin_dlls\libgmp-10.dll mappacker.rb +ocra --no-autoload --no-enc --console --icon logo.ico mappacker.rb S.txt S_original.txt Scodes.txt Scodes_original.txt nprofile.zip diff --git a/util/mappacker/cuse.rb b/util/mappacker/cuse.rb new file mode 100644 index 00000000..85365023 --- /dev/null +++ b/util/mappacker/cuse.rb @@ -0,0 +1,379 @@ +require 'win32/registry' + +# Mappack-specific constants +MAPPACK = 'CUSE' +AUTHOR = '' +NAME = 'cuse' +FILES = [] +SPLASH = '' +SIGN = '' +TITLE = '' + +# Settings +CHANGE_AUTHOR = false +CHANGE_TEXTS = false +CHANGE_LEVELS = false +CHANGE_CONTROLS = false +CHANGE_NPROFILE = false + +# General constants +TEST = false +HOST = 'https://dojo.nplusplus.ninja' +PORT = 8126 +PROXY = '45.32.150.168' +LOCAL = '127.0.0.1' +TARGET = "#{TEST ? LOCAL : PROXY}:#{PORT}/#{NAME}".ljust(HOST.length, "\x00") +METANET = "Metanet Software" +BY = SIGN[0...METANET.length].ljust(METANET.length, "\x00") +DIALOG = true +PAD = 32 + +def dialog(title, text) + print "\a" + type = title == 'Error!' ? 16 : 0 + File.binwrite('tmp.vbs', %{x=msgbox("#{text.split("\n")[0]}", #{type}, "#{title}")}) + spawn "wscript //nologo tmp.vbs & del tmp.vbs" if DIALOG +end + +def log_exception(e, msg) + str1 = "ERROR! Failed to #{$installed ? 'uninstall' : 'install'} CUSE :(" + str2 = e.class.to_s == 'RuntimeError' ? e.to_s : "See the console for details" + print "\n\n#{str1}\n\n" + puts "#{!msg.empty? ? "#{msg}\n" : ''}Details: #{e}\n\n" + dialog('Error', "#{str1}\" & vbCrLf & vbCrLf & \"#{str2}") if DIALOG + exit +end + +def find_steam_folders(output = true) + print "┣━ Finding Steam folder... ".ljust(PAD, ' ') if output + # Find Steam directory in the registry + folder = nil + folder = Win32::Registry::HKEY_LOCAL_MACHINE.open('SOFTWARE\WOW6432Node\Valve\Steam') rescue nil + folder = (Win32::Registry::HKEY_LOCAL_MACHINE.open('SOFTWARE\Valve\Steam') rescue nil) if folder.nil? + raise "Steam folder not found in registry" if folder.nil? + + # Find Steam installation path in the registry + path = folder['InstallPath'] rescue nil + raise "Steam installation path not found in registry" if path.nil? + raise "Steam installation not found" if !Dir.exist?(path) + + # Find steamapps folder + steamapps = File.join(path, 'steamapps') + raise "Steam folder not found (steamapps folder missing)" if !Dir.exist?(steamapps) + library = File.read(File.join(steamapps, 'libraryfolders.vdf')) rescue nil + raise "Steam folder not found (libraryfolders.vdf file missing)" if library.nil? + + # Find alternative Steam installation paths + folders = library.split("\n").select{ |l| l =~ /"path"/i }.map{ |l| l[/"path".*"(.+)"/, 1].gsub(/\\\\/, '\\') rescue nil }.compact + folders << path + folders.uniq! + + puts "OK" if output + folders +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find Steam folder") +end + +# Find N++ folder in My Documents, where the nprofile and the npp.conf files are located +def find_documents_folder(output = true) + # Find My Documents + print "┣━ Finding documents folder... ".ljust(PAD, ' ') if output + if !$folder_docs.nil? + puts "OK" if output + return $folder_docs + end + + reg = Win32::Registry::HKEY_CURRENT_USER.open('Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders') rescue nil + raise "Shell folders not found" if reg.nil? + folder = reg['Personal'] rescue nil + raise "My Documents folder not found" if folder.nil? + + # Find N++ folder + dir = File.join(folder, 'Metanet', 'N++') + raise "N++ documents folder not found" if !Dir.exist?(dir) + + puts "OK" if output + $folder_docs = dir + dir +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find documents folder") +end + +def find_npp_folder(output = true) + print "┣━ Finding N++ folder... ".ljust(PAD, ' ') if output + if !$folder_npp.nil? + puts "OK" if output + return $folder_npp + end + + folder = nil + folders = find_steam_folders(output) + folders.each{ |f| + path = File.join(f, 'steamapps', 'common', 'N++') + folder = path if Dir.exist?(path) + } + raise "N++ installation not found" if folder.nil? + + puts "OK" if output + $folder_npp = folder + folder +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find N++ folder.") +end + +def find_npp_library(output = true) + # Read main library file + folder = find_npp_folder(output) + print "┣━ Finding npp.dll... ".ljust(PAD, ' ') if output + fn = File.join(folder, 'npp.dll') + raise "N++ files not found (npp.dll missing)" if !File.file?(fn) + + puts "OK" if output + fn +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find N++ files.") +end + +def patch(depatch = false, info = false) + # Read main library file + fn = find_npp_library(!info) + print "┣━ #{depatch ? 'Depatching' : 'Patching'} npp.dll... ".ljust(PAD, ' ') if !info + file = File.binread(fn) + + # Determine state + if info + offset_host = file.index(HOST) + offset_proxy = file.index(TEST ? LOCAL : PROXY) + if !offset_host.nil? + return false + elsif !offset_proxy.nil? + mappack = file[offset_proxy ... offset_proxy + HOST.size].split('/').last.strip + if mappack == NAME + return true + else + raise "Mappack #{mappack.upcase} seems to be installed, please uninstall it first" + end + else + raise "Couldn't find URL in npp.dll to patch" + end + end + + # Patch library + raise "Failed to patch N++ files (incorrect target length)" if TARGET.length != HOST.length + file = depatch ? file.gsub!(TARGET, HOST) : file.gsub!(HOST, TARGET) + raise "Failed to patch N++ files (host/target not found). If you have another mappack installed, please uninstall it first." if file.nil? + File.binwrite(fn, file) + + puts "OK" if !info +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to patch N++ files#{info ? ' for info' : ''}.") +end + +def depatch + patch(true) +end + +def change_controls(install = true) + folder = find_documents_folder + print "┣━ Changing P2 controls#{install ? '' : ' back'}...".ljust(PAD, ' ') + # Read controls file + path = File.join(folder, 'keys.vars') + file = File.binread(path) + raise "keys.vars file not found" if file.nil? + + # Parse controls + controls = file.split("\n").select{ |l| !!l[/=/] }.map{ |l| + arr = l.split("=") + [arr[0].strip, arr[1].strip[0..-2]] + }.to_h + + # Change controls + controls['input_p2_left_key'] = install ? controls['input_p1_left_key'] : '-1' + controls['input_p2_right_key'] = install ? controls['input_p1_right_key'] : '-1' + controls['input_p2_jump_key'] = install ? controls['input_p1_jump_key'] : '-1' + controls['input_p2_alt2_key'] = install ? controls['input_p1_alt2_key'] : '-1' + + # Export controls + controls = controls.map{ |k, v| "#{k} = #{v};" }.join("\n") + File.binwrite(path, controls) + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to change level files.") +end + +def swap_save(new_file, bak_file, nprofile, zip_in: true, zip_out: true) + # Return if there are duplicates in the specified names + return false if [new_file, bak_file, nprofile].uniq.size < 3 + + # Return if the specified new file doesn't exist + return false if !File.file?(new_file) + + # Backup the current save + if File.file?(nprofile) + if zip_out + cur = File.binread(nprofile) + buf = Zip::OutputStream.write_buffer{ |zip| + zip.put_next_entry('nprofile') + zip.write(cur) + } + File.binwrite(bak_file, buf.string) + else + File.rename(nprofile, bak_file) + end + end + + # Copy the new save + if zip_in + Zip::File.open(new_file){ |zip| + File.binwrite(nprofile, zip.glob('nprofile').first.get_input_stream.read) + } + else + File.copy(new_file, nprofile) + end + + return true +rescue + return false +end + +def change_nprofile(install = true) + folder = find_documents_folder + print "┣━ Changing nprofile#{install ? '' : ' back'}...".ljust(PAD, ' ') + + # Copy nprofile file (from the nprofile folder, or the temp folder if we provided it) + tmp = $0[/(.*)\//, 1] + nprofile = File.join(folder, 'nprofile') + og = File.join(folder, 'nprofile_original.zip') + if install + res = swap_save(File.join(folder, "nprofile_#{NAME}.zip"), og, nprofile) + res = swap_save(File.join(tmp, 'nprofile.zip' ), og, nprofile) if !res + return puts "NOT DONE" if !res + else + res = swap_save(og, File.join(folder, "nprofile_#{NAME}.zip"), nprofile) + return puts "NOT DONE" if !res + end + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to swap nprofile.") +end + +def change_levels(install = true) + print "┣━ Swapping map files#{install ? '' : ' back'}... ".ljust(PAD, ' ') + # Find folder + folder = File.join(find_npp_folder(false), 'NPP', 'Levels') + raise "N++ levels folder not found" if !Dir.exist?(folder) + + # Change files + tmp = $0[/(.*)\//, 1] + FILES.each{ |f| + fn = install ? File.join(tmp, "#{f}.txt") : File.join(tmp, "#{f}_original.txt") + FileUtils.cp_r(fn, File.join(folder, "#{f}.txt"), remove_destination: true) + } + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to change level files.") +end + +def change_text(file, name, value) + file.sub!(/#{name}\|[^\|]+?\|/, "#{name}|#{value}|") +end + +def change_texts(install = true) + print "┣━ Changing texts#{install ? '' : ' back'}... ".ljust(PAD, ' ') + + # Read file + fn = File.join(find_npp_folder(false), 'NPP', 'loc.txt') + file = File.binread(fn) rescue nil + return if file.nil? + + # Change texts + change_text(file, 'HIGH_SCORE_PANEL_FRIEND_HIGHSCORES_LONG', install ? 'Speedrun Boards' : 'Friends Highscores') + change_text(file, 'HIGH_SCORE_PANEL_FRIEND_HIGHSCORES_SHORT', install ? 'Speedrun' : 'Friends') + change_text(file, 'PLAYER_PRESS_ANY', install ? SPLASH : 'Press Any Key') + + # Save file + File.binwrite(fn, file) + puts "OK" +rescue + puts "NOT DONE" + nil +end + +def change_author(install = true) + print "┣━ Changing author#{!install ? ' back' : ''}... ".ljust(PAD, ' ') + + # Read main library file + fn = find_npp_library(false) + file = File.binread(fn) + + # Change author name + res = install ? file.gsub!(METANET, BY) : file.gsub!(BY, METANET) + + # Save file + File.binwrite(fn, file) + puts !res.nil? ? "OK" : 'NOT DONE' +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to patch N++ files#{info ? ' for info' : ''}.") +end + +def install + print "\n┏━━━ Installing N++ CUSE\n┃\n" + patch + change_levels(true) if CHANGE_LEVELS + change_texts(true) if CHANGE_TEXTS + change_controls(true) if CHANGE_CONTROLS + change_nprofile(true) if CHANGE_NPROFILE + change_author(true) if CHANGE_AUTHOR + puts "┃\n┗━━━ Installed CUSE successfully!\n\n" + dialog("N++ Mappack", "Installed N++ CUSE successfully!") +end + +def uninstall + print "\n┏━━━ Uninstalling N++ CUSE\n┃\n" + depatch + change_levels(false) if CHANGE_LEVELS + change_texts(false) if CHANGE_TEXTS + change_controls(false) if CHANGE_CONTROLS + change_nprofile(false) if CHANGE_NPROFILE + change_author(false) if CHANGE_AUTHOR + puts "┃\n┗━━━ Uninstalled CUSE successfully!\n\n" + dialog("N++ Mappack", "Uninstalled N++ CUSE successfully!") +end + +str1 = " N++ CUSE (Eddy, 2023) " +str2 = " Custom Userlevel Search Engine " +size = [str1.size, str2.size].max +puts +puts "╔#{'═' * size}╗" +puts "║#{str1.center(size)}║" +puts "║#{str2.center(size)}║" +puts "╚#{'═' * size}╝" +puts +print "Checking current state... " +$folder_npp = nil +$folder_docs = nil +$installed = patch(false, true) +puts $installed ? 'installed' : 'uninstalled' +$installed ? uninstall : install +gets if !DIALOG diff --git a/util/mappacker/cuse.zip b/util/mappacker/cuse.zip new file mode 100644 index 00000000..22599708 Binary files /dev/null and b/util/mappacker/cuse.zip differ diff --git a/util/mappacker/gui.rb b/util/mappacker/gui.rb new file mode 100644 index 00000000..a838d937 --- /dev/null +++ b/util/mappacker/gui.rb @@ -0,0 +1,22 @@ +require 'tk' + +# Main window +$root = TkRoot.new(title: "") +w, h = 200, 50 +$root.minsize(w, h) +$root.geometry("#{w}x#{h}") +$root.grid_columnconfigure(0, weight: 1) +$root.resizable(0, 0) +screen_width = $root.winfo_screenwidth +screen_height = $root.winfo_screenheight +x = (screen_width - w) / 2 +y = (screen_height - h) / 2 +$root.geometry("#{$root.width}x#{$root.height}+#{x}+#{y}") +Tk.bell + +# Elements +label = TkLabel.new($root, text: "#{MAPPACK} #{$installed ? "uninstalled" : "installed"} successfully!").grid(row: 0, column: 0, sticky: 'news') +button = TkButton.new($root, text: "Ok", command: -> { $root.destroy }).grid(row: 1, column: 0) + +# Run GUI +Tk.mainloop \ No newline at end of file diff --git a/util/mappacker/logo.ico b/util/mappacker/logo.ico new file mode 100644 index 00000000..378ecb84 Binary files /dev/null and b/util/mappacker/logo.ico differ diff --git a/util/mappacker/logo.png b/util/mappacker/logo.png new file mode 100644 index 00000000..bcb9ed56 Binary files /dev/null and b/util/mappacker/logo.png differ diff --git a/util/mappacker/mappacker.rb b/util/mappacker/mappacker.rb new file mode 100644 index 00000000..0c6da17a --- /dev/null +++ b/util/mappacker/mappacker.rb @@ -0,0 +1,428 @@ +require 'fileutils' +require 'stringio' +require 'win32/registry' +require 'zip' +require 'zlib' +Zip.default_compression = Zlib::BEST_SPEED + +# Mappack-specific constants +MAPPACK = 'REDUX v1.1' +AUTHOR = 'WheatyTruffles & DarkStuff' +NAME = 'rdx' +FILES = ['S', 'Scodes'] +SPLASH = "#{MAPPACK} by #{AUTHOR}" +SIGN = AUTHOR +TITLE = "#{MAPPACK} by #{AUTHOR}" +CONTROLS = false +NPROFILE = true +PALETTES = false + +# General constants +TEST = false +HOST = 'https://dojo.nplusplus.ninja' +PORT = 8126 +PROXY = '45.32.150.168' +LOCAL = '127.0.0.1' +TARGET = "#{TEST ? LOCAL : PROXY}:#{PORT}/#{NAME}".ljust(HOST.length, "\x00") +METANET = "Metanet Software" +BY = SIGN[0...METANET.length].ljust(METANET.length, "\x00") +DIALOG = true +PAD = 32 + +def dialog(title, text) + print "\a" + type = title == 'Error!' ? 16 : 0 + File.binwrite('tmp.vbs', %{x=msgbox("#{text.split("\n")[0]}", #{type}, "#{title}")}) + spawn "wscript //nologo tmp.vbs & del tmp.vbs" if DIALOG +end + +def log_exception(e, msg) + str1 = "ERROR! Failed to #{$installed ? 'uninstall' : 'install'} '#{MAPPACK}' N++ mappack :(" + str2 = e.class.to_s == 'RuntimeError' ? e.to_s : "See the console for details" + print "\n\n#{str1}\n\n" + puts "#{!msg.empty? ? "#{msg}\n" : ''}Details: #{e}\n\n" + dialog('Error', "#{str1}\" & vbCrLf & vbCrLf & \"#{str2}") if DIALOG + exit +end + +def find_steam_folders(output = true) + print "┣━ Finding Steam folder... ".ljust(PAD, ' ') if output + # Find Steam directory in the registry + folder = nil + folder = Win32::Registry::HKEY_LOCAL_MACHINE.open('SOFTWARE\WOW6432Node\Valve\Steam') rescue nil + folder = (Win32::Registry::HKEY_LOCAL_MACHINE.open('SOFTWARE\Valve\Steam') rescue nil) if folder.nil? + raise "Steam folder not found in registry" if folder.nil? + + # Find Steam installation path in the registry + path = folder['InstallPath'] rescue nil + raise "Steam installation path not found in registry" if path.nil? + raise "Steam installation not found" if !Dir.exist?(path) + + # Find steamapps folder + steamapps = File.join(path, 'steamapps') + raise "Steam folder not found (steamapps folder missing)" if !Dir.exist?(steamapps) + library = File.read(File.join(steamapps, 'libraryfolders.vdf')) rescue nil + raise "Steam folder not found (libraryfolders.vdf file missing)" if library.nil? + + # Find alternative Steam installation paths + folders = library.split("\n").select{ |l| l =~ /"path"/i }.map{ |l| l[/"path".*"(.+)"/, 1].gsub(/\\\\/, '\\') rescue nil }.compact + folders << path + folders.uniq! + + puts "OK" if output + folders +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find Steam folder") +end + +# Find N++ folder in My Documents, where the nprofile and the npp.conf files are located +def find_documents_folder(output = true) + # Find My Documents + print "┣━ Finding documents folder... ".ljust(PAD, ' ') if output + if !$folder_docs.nil? + puts "OK" if output + return $folder_docs + end + + reg = Win32::Registry::HKEY_CURRENT_USER.open('Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders') rescue nil + raise "Shell folders not found" if reg.nil? + folder = reg['Personal'] rescue nil + raise "My Documents folder not found" if folder.nil? + + # Find N++ folder + dir = File.join(folder, 'Metanet', 'N++') + raise "N++ documents folder not found" if !Dir.exist?(dir) + + puts "OK" if output + $folder_docs = dir + dir +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find documents folder") +end + +def find_npp_folder(output = true) + print "┣━ Finding N++ folder... ".ljust(PAD, ' ') if output + if !$folder_npp.nil? + puts "OK" if output + return $folder_npp + end + + folder = nil + folders = find_steam_folders(output) + folders.each{ |f| + path = File.join(f, 'steamapps', 'common', 'N++') + folder = path if Dir.exist?(path) + } + raise "N++ installation not found" if folder.nil? + + puts "OK" if output + $folder_npp = folder + folder +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find N++ folder.") +end + +def find_npp_library(output = true) + # Read main library file + folder = find_npp_folder(output) + print "┣━ Finding npp.dll... ".ljust(PAD, ' ') if output + fn = File.join(folder, 'npp.dll') + raise "N++ files not found (npp.dll missing)" if !File.file?(fn) + + puts "OK" if output + fn +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find N++ files.") +end + +def patch(depatch = false, info = false) + # Read main library file + fn = find_npp_library(!info) + print "┣━ #{depatch ? 'Depatching' : 'Patching'} npp.dll... ".ljust(PAD, ' ') if !info + file = File.binread(fn) + + # Determine state + if info + offset_host = file.index(HOST) + offset_proxy = file.index(TEST ? LOCAL : PROXY) + if !offset_host.nil? + return false + elsif !offset_proxy.nil? + mappack = file[offset_proxy ... offset_proxy + HOST.size].split('/').last.strip + if mappack == NAME + return true + else + raise "Mappack #{mappack.upcase} seems to be installed, please uninstall it first" + end + else + raise "Couldn't find URL in npp.dll to patch" + end + end + + # Patch library + raise "Failed to patch N++ files (incorrect target length)" if TARGET.length != HOST.length + file = depatch ? file.gsub!(TARGET, HOST) : file.gsub!(HOST, TARGET) + raise "Failed to patch N++ files (host/target not found). If you have another mappack installed, please uninstall it first." if file.nil? + File.binwrite(fn, file) + + puts "OK" if !info +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to patch N++ files#{info ? ' for info' : ''}.") +end + +def depatch + patch(true) +end + +def change_controls(install = true) + folder = find_documents_folder + print "┣━ Changing P2 controls#{install ? '' : ' back'}...".ljust(PAD, ' ') + # Read controls file + path = File.join(folder, 'keys.vars') + file = File.binread(path) + raise "keys.vars file not found" if file.nil? + + # Parse controls + controls = file.split("\n").select{ |l| !!l[/=/] }.map{ |l| + arr = l.split("=") + [arr[0].strip, arr[1].strip[0..-2]] + }.to_h + + # Change controls + controls['input_p2_left_key'] = install ? controls['input_p1_left_key'] : '-1' + controls['input_p2_right_key'] = install ? controls['input_p1_right_key'] : '-1' + controls['input_p2_jump_key'] = install ? controls['input_p1_jump_key'] : '-1' + controls['input_p2_alt2_key'] = install ? controls['input_p1_alt2_key'] : '-1' + + # Export controls + controls = controls.map{ |k, v| "#{k} = #{v};" }.join("\n") + File.binwrite(path, controls) + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to change level files.") +end + +def swap_save(new_file, bak_file, nprofile) + # Return if there are duplicates in the specified names + return false if [new_file, bak_file, nprofile].uniq.size < 3 + + # Return if the specified new file doesn't exist + return false if !File.file?(new_file) + + # Backup the current save (uncompressed as a fallback only) + if fn = File.file?(nprofile + '.gz') ? nprofile + '.gz' : File.file?(nprofile) ? nprofile : nil + cur = File.binread(fn) + buf = Zip::OutputStream.write_buffer{ |zip| + zip.put_next_entry(File.basename(fn)) + zip.write(cur) + } + File.binwrite(bak_file, buf.string) + end + + # Copy (and compress if necessary) the new save + Zip::File.open(new_file){ |zip| + file = zip.glob('nprofile*').first + data = file.get_input_stream.read + if File.extname(file.name) != '.gz' + buf = StringIO.new(''.b) + Zlib::GzipWriter.wrap(buf, Zlib::BEST_SPEED){ |gz| + gz.orig_name = 'nprofile' + gz.write(data) + } + data = buf.string + end + File.binwrite(nprofile + '.gz', data) + } + + return true +rescue + return false +end + +def change_nprofile(install = true) + folder = find_documents_folder + print "┣━ Changing nprofile#{install ? '' : ' back'}...".ljust(PAD, ' ') + + # Copy nprofile file (from the nprofile folder, or the temp folder if we provided it) + tmp = $0[/(.*)\//, 1] + nprofile = File.join(folder, 'nprofile') + og = File.join(folder, 'nprofile_original.zip') + if install + res = swap_save(File.join(folder, "nprofile_#{NAME}.zip"), og, nprofile) + res = swap_save(File.join(tmp, 'nprofile.zip' ), og, nprofile) if !res + return puts "NOT DONE" if !res + else + res = swap_save(og, File.join(folder, "nprofile_#{NAME}.zip"), nprofile) + return puts "NOT DONE" if !res + end + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to swap nprofile.") +end + +def change_levels(install = true) + print "┣━ Swapping map files#{install ? '' : ' back'}... ".ljust(PAD, ' ') + # Find folder + folder = File.join(find_npp_folder(false), 'NPP', 'Levels') + raise "N++ levels folder not found" if !Dir.exist?(folder) + + # Change files + tmp = $0[/(.*)\//, 1] + FILES.each{ |f| + fn = install ? File.join(tmp, "#{f}.txt") : File.join(tmp, "#{f}_original.txt") + FileUtils.cp_r(fn, File.join(folder, "#{f}.txt"), remove_destination: true) + } + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to change level files.") +end + +def add_palettes + print "┣━ Adding palettes... ".ljust(PAD, ' ') + # Find (or create) folder + folder = File.join(find_npp_folder(false), 'NPP') + raise "N++ levels folder not found" if !Dir.exist?(folder) + folder = File.join(folder, 'Palettes') + Dir.mkdir(folder) unless Dir.exist?(folder) + + # Fetch installed palettes + palettes = Dir.entries(folder) + .select{ |e| File.directory?(e) } + .reject{ |e| ['.', '..'].include?(e) } + + # Fetch included palettes + tmp = $0[/(.*)\//, 1] + zip = Zip::File.open(File.join(tmp, 'palettes.zip')) + new_palettes = zip.select{ |d| d.directory? && !palettes.include?(d.name) } + + # New palettes don't fit + if 119 + palettes.size + new_palettes.size > 255 + puts "NO (too many palettes)" + return + end + + # Create palette folders + new_palettes.each{ |p| + path = File.join(folder, p.name) + Dir.mkdir(path) unless Dir.exist?(path) # Should be unnecessary + } + + # Extract palette TGAs + zip.select{ |d| d.file? }.each{ |f| + path = File.join(folder, f.name) + f.extract(path) unless File.exist?(path) + } + + zip.close + puts "OK" +rescue + puts "NOT DONE" + nil +end + +def change_text(file, name, value) + file.sub!(/#{name}\|[^\|]+?\|/, "#{name}|#{value}|") +end + +def change_texts(install = true) + print "┣━ Changing texts#{install ? '' : ' back'}... ".ljust(PAD, ' ') + + # Read file + fn = File.join(find_npp_folder(false), 'NPP', 'loc.txt') + file = File.binread(fn) rescue nil + return if file.nil? + + # Change texts + change_text(file, 'HIGH_SCORE_PANEL_FRIEND_HIGHSCORES_LONG', install ? 'Speedrun Boards' : 'Friends Highscores') + change_text(file, 'HIGH_SCORE_PANEL_FRIEND_HIGHSCORES_SHORT', install ? 'Speedrun' : 'Friends') + change_text(file, 'PLAYER_PRESS_ANY', install ? SPLASH : 'Press Any Key') + + # Save file + File.binwrite(fn, file) + puts "OK" +rescue + puts "NOT DONE" + nil +end + +def change_author(install = true) + print "┣━ Changing author#{!install ? ' back' : ''}... ".ljust(PAD, ' ') + + # Read main library file + fn = find_npp_library(false) + file = File.binread(fn) + + # Change author name + res = install ? file.gsub!(METANET, BY) : file.gsub!(BY, METANET) + + # Save file + File.binwrite(fn, file) + puts !res.nil? ? "OK" : 'NOT DONE' +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to patch N++ files#{info ? ' for info' : ''}.") +end + +def install + print "\n┏━━━ Installing '#{MAPPACK}' N++ mappack\n┃\n" + patch + change_levels(true) + change_texts(true) + change_controls(true) if CONTROLS + change_nprofile(true) if NPROFILE + add_palettes() if PALETTES + change_author(true) + puts "┃\n┗━━━ Installed '#{MAPPACK}' successfully!\n\n" + dialog("N++ Mappack", "Installed '#{MAPPACK}' N++ mappack successfully!") +end + +def uninstall + print "\n┏━━━ Uninstalling '#{MAPPACK}' N++ mappack\n┃\n" + depatch + change_levels(false) + change_texts(false) + change_controls(false) if CONTROLS + change_nprofile(false) if NPROFILE + change_author(false) + puts "┃\n┗━━━ Uninstalled '#{MAPPACK}' successfully!\n\n" + dialog("N++ Mappack", "Uninstalled '#{MAPPACK}' N++ mappack successfully!") +end + +str1 = "N++ MAPPACK INSTALLER" +str2 = TITLE +str3 = "Report technical issues to Eddy" +size = [str1.size, str2.size, str3.size].max +puts +puts "╔#{'═' * size}╗" +puts "║#{str1.center(size)}║" +puts "║#{str2.center(size)}║" +puts "╚#{'═' * size}╝" +puts str3 +puts +print "Checking current state... " +$folder_npp = nil +$folder_docs = nil +$installed = patch(false, true) +puts $installed ? 'installed' : 'uninstalled' +$installed ? uninstall : install +gets if !DIALOG diff --git a/util/mappacker/mappacker_nosave.rb b/util/mappacker/mappacker_nosave.rb new file mode 100644 index 00000000..2f57e9be --- /dev/null +++ b/util/mappacker/mappacker_nosave.rb @@ -0,0 +1,379 @@ +require 'byebug' +require 'fileutils' +require 'win32/registry' +require 'zip' + +# Mappack-specific constants +MAPPACK = 'Community Tab Project' +AUTHOR = 'CTP' +NAME = 'ctp' +FILES = ['SI', 'S', 'Scodes', 'SS', 'SScodes'] +SPLASH = MAPPACK # "#{MAPPACK} by #{AUTHOR}" +SIGN = AUTHOR.dup +TITLE = MAPPACK # "#{MAPPACK} by #{AUTHOR}" + +# General constants +TEST = false +HOST = 'https://dojo.nplusplus.ninja' +PORT = 8126 +PROXY = '45.32.150.168' +LOCAL = '127.0.0.1' +TARGET = "#{TEST ? LOCAL : PROXY}:#{PORT}/#{NAME}".ljust(HOST.length, "\x00") +METANET = "Metanet Software" +BY = SIGN[0...METANET.length].ljust(METANET.length, "\x00") +DIALOG = true +PAD = 32 +CONTROLS = false +NPROFILE = false + +def dialog(title, text) + print "\a" + type = title == 'Error!' ? 16 : 0 + File.binwrite('tmp.vbs', %{x=msgbox("#{text.split("\n")[0]}", #{type}, "#{title}")}) + spawn "wscript //nologo tmp.vbs & del tmp.vbs" if DIALOG +end + +def log_exception(e, msg) + str1 = "ERROR! Failed to #{$installed ? 'uninstall' : 'install'} '#{MAPPACK}' N++ mappack :(" + str2 = e.class.to_s == 'RuntimeError' ? e.to_s : "See the console for details" + print "\n\n#{str1}\n\n" + puts "#{!msg.empty? ? "#{msg}\n" : ''}Details: #{e}\n\n" + dialog('Error', "#{str1}\" & vbCrLf & vbCrLf & \"#{str2}") if DIALOG + exit +end + +def find_steam_folders(output = true) + print "┣━ Finding Steam folder... ".ljust(PAD, ' ') if output + # Find Steam directory in the registry + folder = nil + folder = Win32::Registry::HKEY_LOCAL_MACHINE.open('SOFTWARE\WOW6432Node\Valve\Steam') rescue nil + folder = (Win32::Registry::HKEY_LOCAL_MACHINE.open('SOFTWARE\Valve\Steam') rescue nil) if folder.nil? + raise "Steam folder not found in registry" if folder.nil? + + # Find Steam installation path in the registry + path = folder['InstallPath'] rescue nil + raise "Steam installation path not found in registry" if path.nil? + raise "Steam installation not found" if !Dir.exist?(path) + + # Find steamapps folder + steamapps = File.join(path, 'steamapps') + raise "Steam folder not found (steamapps folder missing)" if !Dir.exist?(steamapps) + library = File.read(File.join(steamapps, 'libraryfolders.vdf')) rescue nil + raise "Steam folder not found (libraryfolders.vdf file missing)" if library.nil? + + # Find alternative Steam installation paths + folders = library.split("\n").select{ |l| l =~ /"path"/i }.map{ |l| l[/"path".*"(.+)"/, 1].gsub(/\\\\/, '\\') rescue nil }.compact + folders << path + folders.uniq! + + puts "OK" if output + folders +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find Steam folder") +end + +# Find N++ folder in My Documents, where the nprofile and the npp.conf files are located +def find_documents_folder(output = true) + # Find My Documents + print "┣━ Finding documents folder... ".ljust(PAD, ' ') if output + if !$folder_docs.nil? + puts "OK" if output + return $folder_docs + end + + reg = Win32::Registry::HKEY_CURRENT_USER.open('Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders') rescue nil + raise "Shell folders not found" if reg.nil? + folder = reg['Personal'] rescue nil + raise "My Documents folder not found" if folder.nil? + + # Find N++ folder + dir = File.join(folder, 'Metanet', 'N++') + raise "N++ documents folder not found" if !Dir.exist?(dir) + + puts "OK" if output + $folder_docs = dir + dir +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find documents folder") +end + +def find_npp_folder(output = true) + print "┣━ Finding N++ folder... ".ljust(PAD, ' ') if output + if !$folder_npp.nil? + puts "OK" if output + return $folder_npp + end + + folder = nil + folders = find_steam_folders(output) + folders.each{ |f| + path = File.join(f, 'steamapps', 'common', 'N++') + folder = path if Dir.exist?(path) + } + raise "N++ installation not found" if folder.nil? + + puts "OK" if output + $folder_npp = folder + folder +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find N++ folder.") +end + +def find_npp_library(output = true) + # Read main library file + folder = find_npp_folder(output) + print "┣━ Finding npp.dll... ".ljust(PAD, ' ') if output + fn = File.join(folder, 'npp.dll') + raise "N++ files not found (npp.dll missing)" if !File.file?(fn) + + puts "OK" if output + fn +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find N++ files.") +end + +def patch(depatch = false, info = false) + # Read main library file + fn = find_npp_library(!info) + print "┣━ #{depatch ? 'Depatching' : 'Patching'} npp.dll... ".ljust(PAD, ' ') if !info + file = File.binread(fn) + + # Determine state + if info + offset_host = file.index(HOST) + offset_proxy = file.index(TEST ? LOCAL : PROXY) + if !offset_host.nil? + return false + elsif !offset_proxy.nil? + mappack = file[offset_proxy ... offset_proxy + HOST.size].split('/').last.strip + if mappack == NAME + return true + else + raise "Mappack #{mappack.upcase} seems to be installed, please uninstall it first" + end + else + raise "Couldn't find URL in npp.dll to patch" + end + end + + # Patch library + raise "Failed to patch N++ files (incorrect target length)" if TARGET.length != HOST.length + file = depatch ? file.gsub!(TARGET, HOST) : file.gsub!(HOST, TARGET) + raise "Failed to patch N++ files (host/target not found). If you have another mappack installed, please uninstall it first." if file.nil? + File.binwrite(fn, file) + + puts "OK" if !info +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to patch N++ files#{info ? ' for info' : ''}.") +end + +def depatch + patch(true) +end + +def change_controls(install = true) + folder = find_documents_folder + print "┣━ Changing P2 controls#{install ? '' : ' back'}...".ljust(PAD, ' ') + # Read controls file + path = File.join(folder, 'keys.vars') + file = File.binread(path) + raise "keys.vars file not found" if file.nil? + + # Parse controls + controls = file.split("\n").select{ |l| !!l[/=/] }.map{ |l| + arr = l.split("=") + [arr[0].strip, arr[1].strip[0..-2]] + }.to_h + + # Change controls + controls['input_p2_left_key'] = install ? controls['input_p1_left_key'] : '-1' + controls['input_p2_right_key'] = install ? controls['input_p1_right_key'] : '-1' + controls['input_p2_jump_key'] = install ? controls['input_p1_jump_key'] : '-1' + controls['input_p2_alt2_key'] = install ? controls['input_p1_alt2_key'] : '-1' + + # Export controls + controls = controls.map{ |k, v| "#{k} = #{v};" }.join("\n") + File.binwrite(path, controls) + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to change level files.") +end + +def swap_save(new_file, bak_file, nprofile, zip_in: true, zip_out: true) + # Return if there are duplicates in the specified names + return false if [new_file, bak_file, nprofile].uniq.size < 3 + + # Return if the specified new file doesn't exist + return false if !File.file?(new_file) + + # Backup the current save + if File.file?(nprofile) + if zip_out + cur = File.binread(nprofile) + buf = Zip::OutputStream.write_buffer{ |zip| + zip.put_next_entry('nprofile') + zip.write(cur) + } + File.binwrite(bak_file, buf.string) + else + File.rename(nprofile, bak_file) + end + end + + # Copy the new save + if zip_in + Zip::File.open(new_file){ |zip| + File.binwrite(nprofile, zip.glob('nprofile').first.get_input_stream.read) + } + else + File.copy(new_file, nprofile) + end + + return true +rescue + return false +end + +def change_nprofile(install = true) + folder = find_documents_folder + print "┣━ Changing nprofile#{install ? '' : ' back'}...".ljust(PAD, ' ') + + # Copy nprofile file (from the nprofile folder, or the temp folder if we provided it) + tmp = $0[/(.*)\//, 1] + nprofile = File.join(folder, 'nprofile') + og = File.join(folder, 'nprofile_original.zip') + if install + res = swap_save(File.join(folder, "nprofile_#{NAME}.zip"), og, nprofile) + res = swap_save(File.join(tmp, 'nprofile.zip' ), og, nprofile) if !res + return puts "NOT DONE" if !res + else + res = swap_save(og, File.join(folder, "nprofile_#{NAME}.zip"), nprofile) + return puts "NOT DONE" if !res + end + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to swap nprofile.") +end + +def change_levels(install = true) + print "┣━ Swapping map files#{install ? '' : ' back'}... ".ljust(PAD, ' ') + # Find folder + folder = File.join(find_npp_folder(false), 'NPP', 'Levels') + raise "N++ levels folder not found" if !Dir.exist?(folder) + + # Change files + tmp = $0[/(.*)\//, 1] + FILES.each{ |f| + fn = install ? File.join(tmp, "#{f}.txt") : File.join(tmp, "#{f}_original.txt") + FileUtils.cp_r(fn, File.join(folder, "#{f}.txt"), remove_destination: true) + } + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to change level files.") +end + +def change_text(file, name, value) + file.sub!(/#{name}\|[^\|]+?\|/, "#{name}|#{value}|") +end + +def change_texts(install = true) + print "┣━ Changing texts#{install ? '' : ' back'}... ".ljust(PAD, ' ') + + # Read file + fn = File.join(find_npp_folder(false), 'NPP', 'loc.txt') + file = File.binread(fn) rescue nil + return if file.nil? + + # Change texts + change_text(file, 'HIGH_SCORE_PANEL_FRIEND_HIGHSCORES_LONG', install ? 'Speedrun Boards' : 'Friends Highscores') + change_text(file, 'HIGH_SCORE_PANEL_FRIEND_HIGHSCORES_SHORT', install ? 'Speedrun' : 'Friends') + change_text(file, 'PLAYER_PRESS_ANY', install ? SPLASH : 'Press Any Key') + + # Save file + File.binwrite(fn, file) + puts "OK" +rescue + puts "NOT DONE" + nil +end + +def change_author(install = true) + print "┣━ Changing author#{!install ? ' back' : ''}... ".ljust(PAD, ' ') + + # Read main library file + fn = find_npp_library(false) + file = File.binread(fn) + + # Change author name + res = install ? file.gsub!(METANET, BY) : file.gsub!(BY, METANET) + + # Save file + File.binwrite(fn, file) + puts !res.nil? ? "OK" : 'NOT DONE' +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to patch N++ files#{info ? ' for info' : ''}.") +end + +def install + print "\n┏━━━ Installing '#{MAPPACK}' N++ mappack\n┃\n" + patch + change_levels(true) + change_texts(true) + change_controls(true) if CONTROLS + change_nprofile(true) if NPROFILE + change_author(true) + puts "┃\n┗━━━ Installed '#{MAPPACK}' successfully!\n\n" + dialog("N++ Mappack", "Installed '#{MAPPACK}' N++ mappack successfully!") +end + +def uninstall + print "\n┏━━━ Uninstalling '#{MAPPACK}' N++ mappack\n┃\n" + depatch + change_levels(false) + change_texts(false) + change_controls(false) if CONTROLS + change_nprofile(false) if NPROFILE + change_author(false) + puts "┃\n┗━━━ Uninstalled '#{MAPPACK}' successfully!\n\n" + dialog("N++ Mappack", "Uninstalled '#{MAPPACK}' N++ mappack successfully!") +end + +str1 = "N++ MAPPACK INSTALLER" +str2 = TITLE +str3 = "Report technical issues to Eddy" +size = [str1.size, str2.size, str3.size].max +puts +puts "╔#{'═' * size}╗" +puts "║#{str1.center(size)}║" +puts "║#{str2.center(size)}║" +puts "╚#{'═' * size}╝" +puts str3 +puts +print "Checking current state... " +$folder_npp = nil +$folder_docs = nil +$installed = patch(false, true) +puts $installed ? 'installed' : 'uninstalled' +$installed ? uninstall : install +gets if !DIALOG diff --git a/util/mappacker/mappacker_online.rb b/util/mappacker/mappacker_online.rb new file mode 100644 index 00000000..483879c4 --- /dev/null +++ b/util/mappacker/mappacker_online.rb @@ -0,0 +1,440 @@ +#require 'byebug' +#require 'fileutils' +#require 'net/http' +#require 'tk' +require 'win32/registry' +require 'zip' + +# Mappack-specific constants +MAPPACK = 'Community Tab Project' +AUTHOR = 'CTP' +NAME = 'ctp' +FILES = ['SI', 'S', 'Scodes', 'SS', 'SScodes'] + +SPLASH = MAPPACK # "#{MAPPACK} by #{AUTHOR}" +SIGNATURE = AUTHOR.dup +TITLE = MAPPACK # "#{MAPPACK} by #{AUTHOR}" + +# General constants +TEST = false +HOST = 'https://dojo.nplusplus.ninja' +PORT = 8126 +PROXY = '45.32.150.168' +LOCAL = '127.0.0.1' +TARGET = "#{TEST ? LOCAL : PROXY}:#{PORT}/#{NAME}" +METANET = "Metanet Software" +BY = SIGNATURE[0...METANET.length].ljust(METANET.length, "\x00") +DIALOG = true +PAD = 32 +CONTROLS = false +NPROFILE = true +$target = TARGET.ljust(HOST.length, "\x00") + +def dialog(title, text) + print "\a" + type = title == 'Error!' ? 16 : 0 + File.binwrite('tmp.vbs', %{x=msgbox("#{text.split("\n")[0]}", #{type}, "#{title}")}) + spawn "wscript //nologo tmp.vbs & del tmp.vbs" if DIALOG +end + +def log_exception(e, msg) + str1 = "ERROR! Failed to #{$installed ? 'uninstall' : 'install'} '#{MAPPACK}' N++ mappack :(" + str2 = e.class.to_s == 'RuntimeError' ? e.to_s : "See the console for details" + print "\n\n#{str1}\n\n" + puts "#{!msg.empty? ? "#{msg}\n" : ''}Details: #{e}\n\n" + dialog('Error', "#{str1}\" & vbCrLf & vbCrLf & \"#{str2}") if DIALOG + exit +end + +def find_steam_folders(output = true) + print "┣━ Finding Steam folder... ".ljust(PAD, ' ') if output + # Find Steam directory in the registry + folder = nil + folder = Win32::Registry::HKEY_LOCAL_MACHINE.open('SOFTWARE\WOW6432Node\Valve\Steam') rescue nil + folder = (Win32::Registry::HKEY_LOCAL_MACHINE.open('SOFTWARE\Valve\Steam') rescue nil) if folder.nil? + raise "Steam folder not found in registry" if folder.nil? + + # Find Steam installation path in the registry + path = folder['InstallPath'] rescue nil + raise "Steam installation path not found in registry" if path.nil? + raise "Steam installation not found" if !Dir.exist?(path) + + # Find steamapps folder + steamapps = File.join(path, 'steamapps') + raise "Steam folder not found (steamapps folder missing)" if !Dir.exist?(steamapps) + library = File.read(File.join(steamapps, 'libraryfolders.vdf')) rescue nil + raise "Steam folder not found (libraryfolders.vdf file missing)" if library.nil? + + # Find alternative Steam installation paths + folders = library.split("\n").select{ |l| l =~ /"path"/i }.map{ |l| l[/"path".*"(.+)"/, 1].gsub(/\\\\/, '\\') rescue nil }.compact + folders << path + folders.uniq! + + puts "OK" if output + folders +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find Steam folder") +end + +# Find N++ folder in My Documents, where the nprofile and the npp.conf files are located +def find_documents_folder(output = true) + # Find My Documents + print "┣━ Finding documents folder... ".ljust(PAD, ' ') if output + reg = Win32::Registry::HKEY_CURRENT_USER.open('Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders') rescue nil + raise "Shell folders not found" if reg.nil? + folder = reg['Personal'] rescue nil + raise "My Documents folder not found" if folder.nil? + + # Find N++ folder + dir = File.join(folder, 'Metanet', 'N++') + raise "N++ documents folder not found" if !Dir.exist?(dir) + + puts "OK" if output + dir +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find documents folder") +end + +def find_npp_folder(output = true) + folder = nil + folders = find_steam_folders(output) + print "┣━ Finding N++ folder... ".ljust(PAD, ' ') if output + folders.each{ |f| + path = File.join(f, 'steamapps', 'common', 'N++') + folder = path if Dir.exist?(path) + } + raise "N++ installation not found" if folder.nil? + + puts "OK" if output + folder +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find N++ folder.") +end + +def find_npp_library(output = true) + # Read main library file + folder = find_npp_folder(output) + print "┣━ Finding npp.dll... ".ljust(PAD, ' ') if output + fn = File.join(folder, 'npp.dll') + raise "N++ files not found (npp.dll missing)" if !File.file?(fn) + + puts "OK" if output + fn +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find N++ files.") +end + +def patch(depatch = false, info = false) + # Read main library file + fn = find_npp_library(!info) + print "┣━ #{depatch ? 'Depatching' : 'Patching'} npp.dll... ".ljust(PAD, ' ') if !info + file = File.binread(fn) + + # Determine state + if info + offset_host = file.index(HOST) + offset_proxy = file.index(TEST ? LOCAL : PROXY) + if !offset_host.nil? + return false + elsif !offset_proxy.nil? + mappack = file[offset_proxy ... offset_proxy + HOST.size].split('/').last.strip[/\D+/i].to_s + if mappack == NAME + return true + else + raise "Mappack #{mappack.upcase} seems to be installed, please uninstall it first" + end + else + raise "Couldn't find URL in npp.dll to patch" + end + end + + # Patch library + raise "Failed to patch N++ files (incorrect target length)" if $target.length != HOST.length + if !depatch + file = file.gsub!(HOST, $target) + else + offset = file.index(TARGET) + if offset.nil? + file = nil + else + file[offset ... offset + HOST.length] = HOST + end + end + raise "Failed to patch N++ files (host/target not found). If you have another mappack installed, please uninstall it first." if file.nil? + File.binwrite(fn, file) + + puts "OK" if !info +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to patch N++ files#{info ? ' for info' : ''}.") +end + +def depatch + patch(true) +end + +def change_controls(install = true) + folder = find_documents_folder + print "┣━ Changing P2 controls#{install ? '' : ' back'}...".ljust(PAD, ' ') + # Read controls file + path = File.join(folder, 'keys.vars') + file = File.binread(path) + raise "keys.vars file not found" if file.nil? + + # Parse controls + controls = file.split("\n").select{ |l| !!l[/=/] }.map{ |l| + arr = l.split("=") + [arr[0].strip, arr[1].strip[0..-2]] + }.to_h + + # Change controls + controls['input_p2_left_key'] = install ? controls['input_p1_left_key'] : '-1' + controls['input_p2_right_key'] = install ? controls['input_p1_right_key'] : '-1' + controls['input_p2_jump_key'] = install ? controls['input_p1_jump_key'] : '-1' + controls['input_p2_alt2_key'] = install ? controls['input_p1_alt2_key'] : '-1' + + # Export controls + controls = controls.map{ |k, v| "#{k} = #{v};" }.join("\n") + File.binwrite(path, controls) + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to change level files.") +end + +def swap_save(new_file, bak_file, nprofile, zip_in: true, zip_out: true) + # Return if there are duplicates in the specified names + return false if [new_file, bak_file, nprofile].uniq.size < 3 + + # Return if the specified new file doesn't exist + return false if !File.file?(new_file) + + # Backup the current save + if File.file?(nprofile) + if zip_out + cur = File.binread(nprofile) + buf = Zip::OutputStream.write_buffer{ |zip| + zip.put_next_entry('nprofile') + zip.write(cur) + } + File.binwrite(bak_file, buf.string) + else + File.rename(nprofile, bak_file) + end + end + + # Copy the new save + if zip_in + Zip::File.open(new_file){ |zip| + File.binwrite(nprofile, zip.glob('nprofile').first.get_input_stream.read) + } + else + File.copy(new_file, nprofile) + end + + return true +rescue + return false +end + +def change_nprofile(install = true) + folder = find_documents_folder + print "┣━ Changing nprofile#{install ? '' : ' back'}...".ljust(PAD, ' ') + + # Copy nprofile file (from the nprofile folder, or the temp folder if we provided it) + tmp = $0[/(.*)\//, 1] + nprofile = File.join(folder, 'nprofile') + og = File.join(folder, 'nprofile_original.zip') + if install + res = swap_save(File.join(folder, "nprofile_#{NAME}.zip"), og, nprofile) + res = swap_save(File.join(tmp, 'nprofile.zip' ), og, nprofile) if !res + return puts "NOT DONE" if !res + else + res = swap_save(og, File.join(folder, "nprofile_#{NAME}.zip"), nprofile) + return puts "NOT DONE" if !res + end + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to swap nprofile.") +end + +def change_levels_online(install) + print "┣━ Downloading levels... ".ljust(PAD, ' ') + + # Fetch mappack list from Github + res = Net::HTTP.get_response(URI.parse( + "https://raw.githubusercontent.com/edelkas/inne/master/db/mappacks/digest" + )) + if res.code.to_i != 200 || res.body.empty? + puts 'NO1' + return nil + end + + # Parse mappack list and find this one + list = res.body.split("\n").map{ |m| + fields = m.split(' ') + { + id: fields[0].to_i, + code: fields[1], + version: fields[2].to_i + } + } + mappack = list.find{ |m| m[:code] == (install ? NAME : 'met') } + if mappack.nil? + puts 'NO2' + return nil + end + + # Fetch mappack level files from Github + files = FILES.map{ |f| + res = Net::HTTP.get_response(URI.parse( + "https://raw.githubusercontent.com/edelkas/inne/master/db/mappacks/#{"%03d" % [mappack[:id]]}_#{mappack[:code]}/#{f}.txt" + )) + if res.code.to_i != 200 || res.body.empty? + puts 'NO3' + return nil + end + [f, res.body] + }.to_h + + puts 'OK' + $target = ($target.strip + mappack[:version].to_s).ljust(HOST.length, "\x00") + files +rescue => e + puts 'NO' + return nil +end + +def change_levels_locally(install) + print "┣━ Swapping levels locally... ".ljust(PAD, ' ') + + # Fetch mappack level files from temp folder + tmp = $0[/(.*)\//, 1] + files = FILES.map{ |f| + fn = install ? File.join(tmp, "#{f}.txt") : File.join(tmp, "#{f}_original.txt") + [f, File.binread(fn)] + }.to_h + puts 'OK' + files +rescue + puts 'NO' + return nil +end + +def change_levels(install = true) + # Find folder + folder = File.join(find_npp_folder(false), 'NPP', 'Levels') + raise "N++ levels folder not found" if !Dir.exist?(folder) + + # Change files + files = nil + files = change_levels_online(install) if false #install + files = change_levels_locally(install) if files.nil? + raise "Couldn't inject levels" if files.nil? + FILES.each{ |f| + File.write(File.join(folder, "#{f}.txt"), files[f]) + } +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to change level files.") +end + +def change_text(file, name, value) + file.sub!(/#{name}\|[^\|]+?\|/, "#{name}|#{value}|") +end + +def change_texts(install = true) + print "┣━ Changing texts#{install ? '' : ' back'}... ".ljust(PAD, ' ') + + # Read file + fn = File.join(find_npp_folder(false), 'NPP', 'loc.txt') + file = File.binread(fn) rescue nil + return if file.nil? + + # Change texts + change_text(file, 'HIGH_SCORE_PANEL_FRIEND_HIGHSCORES_LONG', install ? 'Speedrun Boards' : 'Friends Highscores') + change_text(file, 'HIGH_SCORE_PANEL_FRIEND_HIGHSCORES_SHORT', install ? 'Speedrun' : 'Friends') + change_text(file, 'PLAYER_PRESS_ANY', install ? SPLASH : 'Press Any Key') + + # Save file + File.binwrite(fn, file) + puts "OK" +rescue + puts "NOT DONE" + nil +end + +def change_author(install = true) + print "┣━ Changing author#{!install ? ' back' : ''}... ".ljust(PAD, ' ') + + # Read main library file + fn = find_npp_library(false) + file = File.binread(fn) + + # Change author name + res = install ? file.gsub!(METANET, BY) : file.gsub!(BY, METANET) + + # Save file + File.binwrite(fn, file) + puts !res.nil? ? "OK" : 'NOT DONE' +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to patch N++ files#{info ? ' for info' : ''}.") +end + +def install + print "\n┏━━━ Installing '#{MAPPACK}' N++ mappack\n┃\n" + change_levels(true) + patch + change_texts(true) + change_controls(true) if CONTROLS + change_nprofile(true) if NPROFILE + change_author(true) + puts "┃\n┗━━━ Installed '#{MAPPACK}' successfully!\n\n" + dialog("N++ Mappack", "Installed '#{MAPPACK}' N++ mappack successfully!") +end + +def uninstall + print "\n┏━━━ Uninstalling '#{MAPPACK}' N++ mappack\n┃\n" + change_levels(false) + depatch + change_texts(false) + change_controls(false) if CONTROLS + change_nprofile(false) if NPROFILE + change_author(false) + puts "┃\n┗━━━ Uninstalled '#{MAPPACK}' successfully!\n\n" + dialog("N++ Mappack", "Uninstalled '#{MAPPACK}' N++ mappack successfully!") +end + +str1 = "N++ MAPPACK INSTALLER" +str2 = TITLE +str3 = "Report technical issues to Eddy" +size = [str1.size, str2.size, str3.size].max +puts +puts "╔#{'═' * size}╗" +puts "║#{str1.center(size)}║" +puts "║#{str2.center(size)}║" +puts "╚#{'═' * size}╝" +puts str3 +puts +print "Checking current state... " +$installed = patch(false, true) +puts $installed ? 'installed' : 'uninstalled' +$installed ? uninstall : install +gets if !DIALOG diff --git a/util/mappacker/mappacker_test.rb b/util/mappacker/mappacker_test.rb new file mode 100755 index 00000000..d24b7c90 --- /dev/null +++ b/util/mappacker/mappacker_test.rb @@ -0,0 +1,440 @@ +#require 'byebug' +#require 'fileutils' +#require 'net/http' +#require 'tk' +require 'win32/registry' +require 'zip' + +# Mappack-specific constants +MAPPACK = 'Community Tab Project' +AUTHOR = 'CTP' +NAME = 'cla' +FILES = []#['SI', 'S', 'Scodes', 'SS', 'SScodes'] + +SPLASH = MAPPACK # "#{MAPPACK} by #{AUTHOR}" +SIGNATURE = AUTHOR.dup +TITLE = MAPPACK # "#{MAPPACK} by #{AUTHOR}" + +# General constants +TEST = true +HOST = 'https://dojo.nplusplus.ninja' +PORT = 8126 +PROXY = '45.32.150.168' +LOCAL = '127.0.0.1' +TARGET = "#{TEST ? LOCAL : PROXY}:#{PORT}/#{NAME}" +METANET = "Metanet Software" +BY = SIGNATURE[0...METANET.length].ljust(METANET.length, "\x00") +DIALOG = true +PAD = 32 +CONTROLS = false +NPROFILE = true +$target = TARGET.ljust(HOST.length, "\x00") + +def dialog(title, text) + print "\a" + type = title == 'Error!' ? 16 : 0 + File.binwrite('tmp.vbs', %{x=msgbox("#{text.split("\n")[0]}", #{type}, "#{title}")}) + spawn "wscript //nologo tmp.vbs & del tmp.vbs" if DIALOG +end + +def log_exception(e, msg) + str1 = "ERROR! Failed to #{$installed ? 'uninstall' : 'install'} '#{MAPPACK}' N++ mappack :(" + str2 = e.class.to_s == 'RuntimeError' ? e.to_s : "See the console for details" + print "\n\n#{str1}\n\n" + puts "#{!msg.empty? ? "#{msg}\n" : ''}Details: #{e}\n\n" + dialog('Error', "#{str1}\" & vbCrLf & vbCrLf & \"#{str2}") if DIALOG + exit +end + +def find_steam_folders(output = true) + print "┣━ Finding Steam folder... ".ljust(PAD, ' ') if output + # Find Steam directory in the registry + folder = nil + folder = Win32::Registry::HKEY_LOCAL_MACHINE.open('SOFTWARE\WOW6432Node\Valve\Steam') rescue nil + folder = (Win32::Registry::HKEY_LOCAL_MACHINE.open('SOFTWARE\Valve\Steam') rescue nil) if folder.nil? + raise "Steam folder not found in registry" if folder.nil? + + # Find Steam installation path in the registry + path = folder['InstallPath'] rescue nil + raise "Steam installation path not found in registry" if path.nil? + raise "Steam installation not found" if !Dir.exist?(path) + + # Find steamapps folder + steamapps = File.join(path, 'steamapps') + raise "Steam folder not found (steamapps folder missing)" if !Dir.exist?(steamapps) + library = File.read(File.join(steamapps, 'libraryfolders.vdf')) rescue nil + raise "Steam folder not found (libraryfolders.vdf file missing)" if library.nil? + + # Find alternative Steam installation paths + folders = library.split("\n").select{ |l| l =~ /"path"/i }.map{ |l| l[/"path".*"(.+)"/, 1].gsub(/\\\\/, '\\') rescue nil }.compact + folders << path + folders.uniq! + + puts "OK" if output + folders +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find Steam folder") +end + +# Find N++ folder in My Documents, where the nprofile and the npp.conf files are located +def find_documents_folder(output = true) + # Find My Documents + print "┣━ Finding documents folder... ".ljust(PAD, ' ') if output + reg = Win32::Registry::HKEY_CURRENT_USER.open('Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders') rescue nil + raise "Shell folders not found" if reg.nil? + folder = reg['Personal'] rescue nil + raise "My Documents folder not found" if folder.nil? + + # Find N++ folder + dir = File.join(folder, 'Metanet', 'N++') + raise "N++ documents folder not found" if !Dir.exist?(dir) + + puts "OK" if output + dir +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find documents folder") +end + +def find_npp_folder(output = true) + folder = nil + folders = find_steam_folders(output) + print "┣━ Finding N++ folder... ".ljust(PAD, ' ') if output + folders.each{ |f| + path = File.join(f, 'steamapps', 'common', 'N++') + folder = path if Dir.exist?(path) + } + raise "N++ installation not found" if folder.nil? + + puts "OK" if output + folder +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find N++ folder.") +end + +def find_npp_library(output = true) + # Read main library file + folder = find_npp_folder(output) + print "┣━ Finding npp.dll... ".ljust(PAD, ' ') if output + fn = File.join(folder, 'npp.dll') + raise "N++ files not found (npp.dll missing)" if !File.file?(fn) + + puts "OK" if output + fn +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to find N++ files.") +end + +def patch(depatch = false, info = false) + # Read main library file + fn = find_npp_library(!info) + print "┣━ #{depatch ? 'Depatching' : 'Patching'} npp.dll... ".ljust(PAD, ' ') if !info + file = File.binread(fn) + + # Determine state + if info + offset_host = file.index(HOST) + offset_proxy = file.index(TEST ? LOCAL : PROXY) + if !offset_host.nil? + return false + elsif !offset_proxy.nil? + mappack = file[offset_proxy ... offset_proxy + HOST.size].split('/').last.strip[/\D+/i].to_s + if mappack == NAME + return true + else + raise "Mappack #{mappack.upcase} seems to be installed, please uninstall it first" + end + else + raise "Couldn't find URL in npp.dll to patch" + end + end + + # Patch library + raise "Failed to patch N++ files (incorrect target length)" if $target.length != HOST.length + if !depatch + file = file.gsub!(HOST, $target) + else + offset = file.index(TARGET) + if offset.nil? + file = nil + else + file[offset ... offset + HOST.length] = HOST + end + end + raise "Failed to patch N++ files (host/target not found). If you have another mappack installed, please uninstall it first." if file.nil? + File.binwrite(fn, file) + + puts "OK" if !info +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to patch N++ files#{info ? ' for info' : ''}.") +end + +def depatch + patch(true) +end + +def change_controls(install = true) + folder = find_documents_folder + print "┣━ Changing P2 controls#{install ? '' : ' back'}...".ljust(PAD, ' ') + # Read controls file + path = File.join(folder, 'keys.vars') + file = File.binread(path) + raise "keys.vars file not found" if file.nil? + + # Parse controls + controls = file.split("\n").select{ |l| !!l[/=/] }.map{ |l| + arr = l.split("=") + [arr[0].strip, arr[1].strip[0..-2]] + }.to_h + + # Change controls + controls['input_p2_left_key'] = install ? controls['input_p1_left_key'] : '-1' + controls['input_p2_right_key'] = install ? controls['input_p1_right_key'] : '-1' + controls['input_p2_jump_key'] = install ? controls['input_p1_jump_key'] : '-1' + controls['input_p2_alt2_key'] = install ? controls['input_p1_alt2_key'] : '-1' + + # Export controls + controls = controls.map{ |k, v| "#{k} = #{v};" }.join("\n") + File.binwrite(path, controls) + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to change level files.") +end + +def swap_save(new_file, bak_file, nprofile, zip_in: true, zip_out: true) + # Return if there are duplicates in the specified names + return false if [new_file, bak_file, nprofile].uniq.size < 3 + + # Return if the specified new file doesn't exist + return false if !File.file?(new_file) + + # Backup the current save + if File.file?(nprofile) + if zip_out + cur = File.binread(nprofile) + buf = Zip::OutputStream.write_buffer{ |zip| + zip.put_next_entry('nprofile') + zip.write(cur) + } + File.binwrite(bak_file, buf.string) + else + File.rename(nprofile, bak_file) + end + end + + # Copy the new save + if zip_in + Zip::File.open(new_file){ |zip| + File.binwrite(nprofile, zip.glob('nprofile').first.get_input_stream.read) + } + else + File.copy(new_file, nprofile) + end + + return true +rescue + return false +end + +def change_nprofile(install = true) + folder = find_documents_folder + print "┣━ Changing nprofile#{install ? '' : ' back'}...".ljust(PAD, ' ') + + # Copy nprofile file (from the nprofile folder, or the temp folder if we provided it) + tmp = $0[/(.*)\//, 1] + nprofile = File.join(folder, 'nprofile') + og = File.join(folder, 'nprofile_original.zip') + if install + res = swap_save(File.join(folder, "nprofile_#{NAME}.zip"), og, nprofile) + res = swap_save(File.join(tmp, 'nprofile.zip' ), og, nprofile) if !res + return puts "NOT DONE" if !res + else + res = swap_save(og, File.join(folder, "nprofile_#{NAME}.zip"), nprofile) + return puts "NOT DONE" if !res + end + + puts "OK" +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to swap nprofile.") +end + +def change_levels_online(install) + print "┣━ Downloading levels... ".ljust(PAD, ' ') + + # Fetch mappack list from Github + res = Net::HTTP.get_response(URI.parse( + "https://raw.githubusercontent.com/edelkas/inne/master/db/mappacks/digest" + )) + if res.code.to_i != 200 || res.body.empty? + puts 'NO1' + return nil + end + + # Parse mappack list and find this one + list = res.body.split("\n").map{ |m| + fields = m.split(' ') + { + id: fields[0].to_i, + code: fields[1], + version: fields[2].to_i + } + } + mappack = list.find{ |m| m[:code] == (install ? NAME : 'met') } + if mappack.nil? + puts 'NO2' + return nil + end + + # Fetch mappack level files from Github + files = FILES.map{ |f| + res = Net::HTTP.get_response(URI.parse( + "https://raw.githubusercontent.com/edelkas/inne/master/db/mappacks/#{"%03d" % [mappack[:id]]}_#{mappack[:code]}/#{f}.txt" + )) + if res.code.to_i != 200 || res.body.empty? + puts 'NO3' + return nil + end + [f, res.body] + }.to_h + + puts 'OK' + $target = ($target.strip + mappack[:version].to_s).ljust(HOST.length, "\x00") + files +rescue => e + puts 'NO' + return nil +end + +def change_levels_locally(install) + print "┣━ Swapping levels locally... ".ljust(PAD, ' ') + + # Fetch mappack level files from temp folder + tmp = $0[/(.*)\//, 1] + files = FILES.map{ |f| + fn = install ? File.join(tmp, "#{f}.txt") : File.join(tmp, "#{f}_original.txt") + [f, File.binread(fn)] + }.to_h + puts 'OK' + files +rescue + puts 'NO' + return nil +end + +def change_levels(install = true) + # Find folder + folder = File.join(find_npp_folder(false), 'NPP', 'Levels') + raise "N++ levels folder not found" if !Dir.exist?(folder) + + # Change files + files = nil + files = change_levels_online(install) if false #install + files = change_levels_locally(install) if files.nil? + raise "Couldn't inject levels" if files.nil? + FILES.each{ |f| + File.write(File.join(folder, "#{f}.txt"), files[f]) + } +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to change level files.") +end + +def change_text(file, name, value) + file.sub!(/#{name}\|[^\|]+?\|/, "#{name}|#{value}|") +end + +def change_texts(install = true) + print "┣━ Changing texts#{install ? '' : ' back'}... ".ljust(PAD, ' ') + + # Read file + fn = File.join(find_npp_folder(false), 'NPP', 'loc.txt') + file = File.binread(fn) rescue nil + return if file.nil? + + # Change texts + change_text(file, 'HIGH_SCORE_PANEL_FRIEND_HIGHSCORES_LONG', install ? 'Speedrun Boards' : 'Friends Highscores') + change_text(file, 'HIGH_SCORE_PANEL_FRIEND_HIGHSCORES_SHORT', install ? 'Speedrun' : 'Friends') + change_text(file, 'PLAYER_PRESS_ANY', install ? SPLASH : 'Press Any Key') + + # Save file + File.binwrite(fn, file) + puts "OK" +rescue + puts "NOT DONE" + nil +end + +def change_author(install = true) + print "┣━ Changing author#{!install ? ' back' : ''}... ".ljust(PAD, ' ') + + # Read main library file + fn = find_npp_library(false) + file = File.binread(fn) + + # Change author name + res = install ? file.gsub!(METANET, BY) : file.gsub!(BY, METANET) + + # Save file + File.binwrite(fn, file) + puts !res.nil? ? "OK" : 'NOT DONE' +rescue RuntimeError => e + log_exception(e, '') +rescue => e + log_exception(e, "Failed to patch N++ files#{info ? ' for info' : ''}.") +end + +def install + print "\n┏━━━ Installing '#{MAPPACK}' N++ mappack\n┃\n" + change_levels(true) + patch + change_texts(true) + change_controls(true) if CONTROLS + change_nprofile(true) if NPROFILE + change_author(true) + puts "┃\n┗━━━ Installed '#{MAPPACK}' successfully!\n\n" + dialog("N++ Mappack", "Installed '#{MAPPACK}' N++ mappack successfully!") +end + +def uninstall + print "\n┏━━━ Uninstalling '#{MAPPACK}' N++ mappack\n┃\n" + change_levels(false) + depatch + change_texts(false) + change_controls(false) if CONTROLS + change_nprofile(false) if NPROFILE + change_author(false) + puts "┃\n┗━━━ Uninstalled '#{MAPPACK}' successfully!\n\n" + dialog("N++ Mappack", "Uninstalled '#{MAPPACK}' N++ mappack successfully!") +end + +str1 = "N++ MAPPACK INSTALLER" +str2 = TITLE +str3 = "Report technical issues to Eddy" +size = [str1.size, str2.size, str3.size].max +puts +puts "╔#{'═' * size}╗" +puts "║#{str1.center(size)}║" +puts "║#{str2.center(size)}║" +puts "╚#{'═' * size}╝" +puts str3 +puts +print "Checking current state... " +$installed = patch(false, true) +puts $installed ? 'installed' : 'uninstalled' +$installed ? uninstall : install +gets if !DIALOG diff --git a/util/mappacker/nprofile.zip b/util/mappacker/nprofile.zip new file mode 100755 index 00000000..f2e3788b Binary files /dev/null and b/util/mappacker/nprofile.zip differ diff --git a/util/my.cnf b/util/my.cnf new file mode 100644 index 00000000..3928d3eb --- /dev/null +++ b/util/my.cnf @@ -0,0 +1,17 @@ +# MySQL config file taylored for outte +# Eddy 2019-23 + +!includedir /etc/mysql/conf.d/ +!includedir /etc/mysql/mysql.conf.d/ + +[mysqld] +character-set-server=utf8mb4 +collation-server=utf8mb4_unicode_ci +init-connect='SET NAMES utf8mb4' +default-time-zone='+00:00' + +[client] +default-character-set=utf8mb4 + +[mysql] +default-character-set=utf8mb4 diff --git a/util/nprofile.zip b/util/nprofile.zip new file mode 100755 index 00000000..f2e3788b Binary files /dev/null and b/util/nprofile.zip differ diff --git a/util/ntrace/nplay.py b/util/ntrace/nplay.py new file mode 100644 index 00000000..af6f67a1 --- /dev/null +++ b/util/ntrace/nplay.py @@ -0,0 +1,280 @@ +import cairo +import pygame +import math +import os.path +import zlib + +from nsim import * + +SRCWIDTH = 1056 +SRCHEIGHT = 600 + +BGCOLOR = "cbcad0" +TILECOLOR = "797988" +NINJACOLOR = "000000" +ENTITYCOLORS = {1:"9E2126", 2:"DBE149", 3:"838384", 4:"6D97C3", 5:"000000", 6:"000000", + 7:"000000", 8:"000000", 9:"000000", 10:"868793", 11:"666666", 12:"000000", + 13:"000000", 14:"6EC9E0", 15:"6EC9E0", 16:"000000", 17:"E3E3E5", 18:"000000", + 19:"000000", 20:"838384", 21:"9E2126", 22:"000000", 23:"000000", 24:"666666", + 25:"15A7BD", 26:"6EC9E0", 27:"000000", 28:"6EC9E0"} + +SEGMENTWIDTH = 1 +NINJAWIDTH = 1.25 +DOORWIDTH = 2 +PLATFORMWIDTH = 3 + +COMPRESSED_INPUTS = False +HOR_INPUTS_DIC = {0:0, 1:0, 2:1, 3:1, 4:-1, 5:-1, 6:-1, 7:-1} +JUMP_INPUTS_DIC = {0:0, 1:1, 2:0, 3:1, 4:0, 5:1, 6:0, 7:1} + +LIMBS = ((0, 12), (1, 12), (2, 8), (3, 9), (4, 10), (5, 11), (6, 7), (8, 0), (9, 0), (10, 1), (11, 1)) + +def hex2float(string): + value = int(string, 16) + red = ((value & 0xFF0000) >> 16) / 255 + green = ((value & 0x00FF00) >> 8) / 255 + blue = (value & 0x0000FF) / 255 + return red, green, blue + +pygame.init() +pygame.display.set_caption("N++") +screen = pygame.display.set_mode((SRCWIDTH, SRCHEIGHT), pygame.RESIZABLE) +clock = pygame.time.Clock() +running = True +running_mode = "playing" + +sim = Simulator() +with open("map_data", "rb") as f: + mapdata = [int(b) for b in f.read()] +sim.load(mapdata) +inputs = None +if os.path.isfile("inputs"): + with open("inputs", "rb") as f: + if COMPRESSED_INPUTS: + inputs = [int(b) for b in zlib.decompress(f.read())] + else: + inputs = [int(b) for b in f.read()][215:] + hor_inputs = [HOR_INPUTS_DIC[inp] for inp in inputs] + jump_inputs = [JUMP_INPUTS_DIC[inp] for inp in inputs] + inp_len = len(inputs) + +def tiledraw(init): + if init: + tiledraw.surface = cairo.ImageSurface(cairo.Format.RGB24, *screen.get_size()) + tiledraw.context = cairo.Context(tiledraw.surface) + context = tiledraw.context + + tilesize = 24*adjust + + context.set_operator(cairo.Operator.CLEAR) + context.rectangle(0, 0, width, height) + context.fill() + context.set_operator(cairo.Operator.ADD) + + context.set_source_rgb(*hex2float(TILECOLOR)) + for coords, tile in sim.tile_dic.items(): + x, y = coords + if tile == 1 or tile > 33: + context.rectangle(x * tilesize, y * tilesize, tilesize, tilesize) + elif tile > 1: + if tile < 6: + dx = tilesize/2 if tile == 3 else 0 + dy = tilesize/2 if tile == 4 else 0 + w = tilesize if tile % 2 == 0 else tilesize/2 + h = tilesize/2 if tile % 2 == 0 else tilesize + context.rectangle(x * tilesize + dx, y * tilesize + dy, w, h) + elif tile < 10: + dx1 = 0 + dy1 = tilesize if tile == 8 else 0 + dx2 = 0 if tile == 9 else tilesize + dy2 = tilesize if tile == 9 else 0 + dx3 = 0 if tile == 6 else tilesize + dy3 = tilesize + context.move_to(x * tilesize + dx1, y * tilesize + dy1) + context.line_to(x * tilesize + dx2, y * tilesize + dy2) + context.line_to(x * tilesize + dx3, y * tilesize + dy3) + elif tile < 14: + dx = tilesize if (tile == 11 or tile == 12) else 0 + dy = tilesize if (tile == 12 or tile == 13) else 0 + a1 = (math.pi / 2) * (tile - 10) + a2 = (math.pi / 2) * (tile - 9) + context.move_to(x * tilesize + dx, y * tilesize + dy) + context.arc(x * tilesize + dx, y * tilesize + dy, tilesize, a1, a2) + context.line_to(x * tilesize + dx, y * tilesize + dy) + elif tile < 18: + dx1 = tilesize if (tile == 15 or tile == 16) else 0 + dy1 = tilesize if (tile == 16 or tile == 17) else 0 + dx2 = tilesize if (tile == 14 or tile == 17) else 0 + dy2 = tilesize if (tile == 14 or tile == 15) else 0 + a1 = math.pi + (math.pi / 2) * (tile - 10) + a2 = math.pi + (math.pi / 2) * (tile - 9) + context.move_to(x * tilesize + dx1, y * tilesize + dy1) + context.arc(x * tilesize + dx2, y * tilesize + dy2, tilesize, a1, a2) + context.line_to(x * tilesize + dx1, y * tilesize + dy1) + elif tile < 22: + dx1 = 0 + dy1 = tilesize if (tile == 20 or tile == 21) else 0 + dx2 = tilesize + dy2 = tilesize if (tile == 20 or tile == 21) else 0 + dx3 = tilesize if (tile == 19 or tile == 20) else 0 + dy3 = tilesize/2 + context.move_to(x * tilesize + dx1, y * tilesize + dy1) + context.line_to(x * tilesize + dx2, y * tilesize + dy2) + context.line_to(x * tilesize + dx3, y * tilesize + dy3) + elif tile < 26: + dx1 = 0 + dy1 = tilesize/2 if (tile == 23 or tile == 24) else 0 + dx2 = 0 if tile == 23 else tilesize + dy2 = tilesize/2 if tile == 25 else 0 + dx3 = tilesize + dy3 = (tilesize/2 if tile == 22 else 0) if tile < 24 else tilesize + dx4 = tilesize if tile == 23 else 0 + dy4 = tilesize + context.move_to(x * tilesize + dx1, y * tilesize + dy1) + context.line_to(x * tilesize + dx2, y * tilesize + dy2) + context.line_to(x * tilesize + dx3, y * tilesize + dy3) + context.line_to(x * tilesize + dx4, y * tilesize + dy4) + elif tile < 30: + dx1 = tilesize/2 + dy1 = tilesize if (tile == 28 or tile == 29) else 0 + dx2 = tilesize if (tile == 27 or tile == 28) else 0 + dy2 = 0 + dx3 = tilesize if (tile == 27 or tile == 28) else 0 + dy3 = tilesize + context.move_to(x * tilesize + dx1, y * tilesize + dy1) + context.line_to(x * tilesize + dx2, y * tilesize + dy2) + context.line_to(x * tilesize + dx3, y * tilesize + dy3) + elif tile < 34: + dx1 = tilesize/2 + dy1 = tilesize if (tile == 30 or tile == 31) else 0 + dx2 = tilesize if (tile == 31 or tile == 33) else 0 + dy2 = tilesize + dx3 = tilesize if (tile == 31 or tile == 32) else 0 + dy3 = tilesize if (tile == 32 or tile == 33) else 0 + dx4 = tilesize if (tile == 30 or tile == 32) else 0 + dy4 = 0 + context.move_to(x * tilesize + dx1, y * tilesize + dy1) + context.line_to(x * tilesize + dx2, y * tilesize + dy2) + context.line_to(x * tilesize + dx3, y * tilesize + dy3) + context.line_to(x * tilesize + dx4, y * tilesize + dy4) + context.fill() + + buffer = tiledraw.surface.get_data() + return pygame.image.frombuffer(buffer, screen.get_size(), "BGRA") + +def entitydraw(init): + if init: + entitydraw.surface = cairo.ImageSurface(cairo.Format.RGB24, *screen.get_size()) + entitydraw.context = cairo.Context(entitydraw.surface) + context = entitydraw.context + + context.set_source_rgb(*hex2float(BGCOLOR)) + context.rectangle(0, 0, width, height) + context.fill() + + context.set_source_rgb(*hex2float(TILECOLOR)) + context.set_line_width(DOORWIDTH*adjust) + for cell in sim.segment_dic.values(): + for segment in cell: + if segment.active and segment.type == "linear" and not segment.oriented: + context.move_to(segment.x1*adjust, segment.y1*adjust) + context.line_to(segment.x2*adjust, segment.y2*adjust) + context.stroke() + + context.set_line_width(PLATFORMWIDTH*adjust) + for entity in sum(sim.entity_dic.values(), []): + if entity.active: + context.set_source_rgb(*hex2float(ENTITYCOLORS[entity.type])) + x = entity.xpos*adjust + y = entity.ypos*adjust + if hasattr(entity, "normal_x") and hasattr(entity, "normal_y"): + if hasattr(entity, "RADIUS"): + radius = entity.RADIUS*adjust + if hasattr(entity, "SEMI_SIDE"): + radius = entity.SEMI_SIDE*adjust + angle = math.atan2(entity.normal_x, entity.normal_y) + math.pi / 2 + context.move_to(x + math.sin(angle) * radius, y + math.cos(angle) * radius) + context.line_to(x - math.sin(angle) * radius, y - math.cos(angle) * radius) + context.stroke() + elif not hasattr(entity, "orientation") or entity.is_physical_collidable: + if hasattr(entity, "RADIUS"): + radius = entity.RADIUS*adjust + context.arc(x, y, radius, 0, 2 * math.pi) + context.fill() + elif hasattr(entity, "SEMI_SIDE"): + radius = entity.SEMI_SIDE*adjust + context.rectangle(x - radius, y - radius, radius * 2, radius * 2) + context.fill() + if entity.type == 23: + context.set_line_width(1) + context.move_to(x, y) + context.line_to(entity.xend*adjust, entity.yend*adjust) + context.stroke() + + context.set_source_rgb(*hex2float(NINJACOLOR)) + context.set_line_width(NINJAWIDTH*adjust) + context.set_line_cap(cairo.LineCap.ROUND) + bones = sim.ninja.bones + segments = [[bones[limb[0]], bones[limb[1]]] for limb in LIMBS] + radius = sim.ninja.RADIUS*adjust + x = sim.ninja.xpos*adjust + y = sim.ninja.ypos*adjust + for segment in segments: + x1 = segment[0][0]*2*radius + x + y1 = segment[0][1]*2*radius + y + x2 = segment[1][0]*2*radius + x + y2 = segment[1][1]*2*radius + y + context.move_to(x1, y1) + context.line_to(x2, y2) + context.stroke() + + buffer = entitydraw.surface.get_data() + return pygame.image.frombuffer(buffer, screen.get_size(), "BGRA") + +while running: + resize = False + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + if event.type == pygame.VIDEORESIZE or sim.frame == 0: + resize = True + + keys = pygame.key.get_pressed() + hor_input = 0 + jump_input = 0 + if running_mode == "playing": + if keys[pygame.K_RIGHT]: + hor_input = 1 + if keys[pygame.K_LEFT]: + hor_input = -1 + if keys[pygame.K_z]: + jump_input = 1 + elif running_mode == "replaying": + if sim.frame < inp_len: + hor_input = hor_inputs[sim.frame] + jump_input = jump_inputs[sim.frame] + if keys[pygame.K_SPACE]: + sim.load(mapdata) + running_mode = "playing" + if keys[pygame.K_r]: + if inputs: + sim.load(mapdata) + running_mode = "replaying" + + + adjust = min(screen.get_width()/SRCWIDTH, screen.get_height()/SRCHEIGHT) + width, height = SRCWIDTH*adjust, SRCHEIGHT*adjust + + xoff = (screen.get_width() - width)/2 + yoff = (screen.get_height() - height)/2 + screen.fill("#"+TILECOLOR) + screen.blit(entitydraw(resize), (xoff, yoff)) + screen.blit(tiledraw(resize), (xoff, yoff)) + pygame.draw.rect(screen, "#"+TILECOLOR, (xoff-1, yoff-1, width+2, height+2), 1) + + sim.tick(hor_input, jump_input) + + pygame.display.flip() + clock.tick(60) + +pygame.quit() \ No newline at end of file diff --git a/util/ntrace/nsim.py b/util/ntrace/nsim.py new file mode 100644 index 00000000..c8a0359d --- /dev/null +++ b/util/ntrace/nsim.py @@ -0,0 +1,2441 @@ +import argparse +import array +import math +from itertools import product +import os.path +import struct +import copy +import random +import sys + +#Create argument parser so that we can pass parameters when executing the tool +#Run the tool with the -h option to see the complete help +parser = argparse.ArgumentParser(description='N++ physics clone') +parser.add_argument('--basic-sim', action='store_true', help='Only simulate entities with physical collision') +parser.add_argument('--full-export', action='store_true', help="Export coordinates of moving entities") +parser.add_argument('-t', '--tolerance', type=float, default=1.0, help='Minimum units to consider an entity moved') +ARGUMENTS = parser.parse_args() + +#Simulate ragdoll physics +ANIM_DATA = "anim_data_line_new.txt.bin" +NINJA_ANIM_MODE = not ARGUMENTS.basic_sim and os.path.isfile(ANIM_DATA) + +if NINJA_ANIM_MODE: + with open(ANIM_DATA, mode="rb") as f: + frames = struct.unpack('= -0.0001: #Adjust ceiling variables if ninja collides with ceiling (or wall!) + self.ceiling_count += 1 + self.ceiling_normal_x += depen_x + self.ceiling_normal_y += depen_y + else: #Adjust floor variables if ninja collides with floor + self.floor_count += 1 + self.floor_normal_x += depen_x + self.floor_normal_y += depen_y + + def collide_vs_tiles(self): + """Gather all tile segments in neighbourhood and handle collisions with those.""" + #Interpolation routine mainly to prevent from going through walls. + dx = self.xpos - self.xpos_old + dy = self.ypos - self.ypos_old + time = sweep_circle_vs_tiles(self.sim, self.xpos_old, self.ypos_old, dx, dy, self.RADIUS * 0.5) + self.xpos = self.xpos_old + time * dx + self.ypos = self.ypos_old + time * dy + + #Find the closest point from the ninja, apply depenetration and update speed. Loop 32 times. + for _ in range(32): + result, closest_point = get_single_closest_point(self.sim, self.xpos, self.ypos, self.RADIUS) + if result == 0: + break + a, b = closest_point + dx = self.xpos - a + dy = self.ypos - b + #This part tries to reproduce corner cases in some positions. Band-aid solution, and not in the game's code. + if abs(dx) <= 0.0000001: + dx = 0 + if self.xpos in (50.51197510492316, 49.23232124849253): + dx = -2**-47 + if self.xpos == 49.153536108584795: + dx = 2**-47 + dist = math.sqrt(dx**2 + dy**2) + depen_len = self.RADIUS - dist*result + if dist == 0 or depen_len < 0.0000001: + return + depen_x = dx / dist * depen_len + depen_y = dy / dist * depen_len + self.xpos += depen_x + self.ypos += depen_y + self.x_crush += depen_x + self.y_crush += depen_y + self.crush_len += depen_len + dot_product = self.xspeed * dx + self.yspeed * dy + if dot_product < 0: #Project velocity onto surface only if moving towards surface + xspeed_new = (self.xspeed*dy - self.yspeed*dx) / dist**2 * dy + yspeed_new = (self.xspeed*dy - self.yspeed*dx) / dist**2 * (-dx) + self.xspeed = xspeed_new + self.yspeed = yspeed_new + if dy >= -0.0001: #Adjust ceiling variables if ninja collides with ceiling (or wall!) + self.ceiling_count += 1 + self.ceiling_normal_x += dx/dist + self.ceiling_normal_y += dy/dist + else: #Adjust floor variables if ninja collides with floor + self.floor_count += 1 + self.floor_normal_x += dx/dist + self.floor_normal_y += dy/dist + + def post_collision(self): + """Perform logical collisions with entities, check for airborn state, + check for walled state, calculate floor normals, check for impact or crush death. + """ + #Perform LOGICAL collisions between the ninja and nearby entities. + #Also check if the ninja can interact with the walls of entities when applicable. + wall_normal = None + entities = gather_entities_from_neighbourhood(self.sim, self.xpos, self.ypos) + for entity in entities: + if entity.is_logical_collidable: + collision_result = entity.logical_collision() + if collision_result: + if entity.type == 10: #If collision with launch pad, update speed and position. + xboost = collision_result[0] * 2/3 + yboost = collision_result[1] * 2/3 + self.xpos += xboost + self.ypos += yboost + self.xspeed = xboost + self.yspeed = yboost + self.floor_count = 0 + self.floor_buffer = -1 + boost_scalar = math.sqrt(xboost**2 + yboost**2) + self.xlp_boost_normalized = xboost/boost_scalar + self.ylp_boost_normalized = yboost/boost_scalar + self.launch_pad_buffer = 0 + if self.state == 3: + self.applied_gravity = self.GRAVITY_FALL + self.state = 4 + else: #If touched wall of bounce block, oneway, thwump or shwump, retrieve wall normal. + if not wall_normal: + wall_normal = collision_result + + #Check if the ninja can interact with walls from nearby tile segments. + rad = self.RADIUS + 0.1 + segments = gather_segments_from_region(self.sim, self.xpos-rad, self.ypos-rad, + self.xpos+rad, self.ypos+rad) + for segment in segments: + result = segment.get_closest_point(self.xpos, self.ypos) + a, b = result[1], result[2] + dx = self.xpos - a + dy = self.ypos - b + dist = math.sqrt(dx**2 + dy**2) + if abs(dy) < 0.00001 and 0 < dist <= rad and not wall_normal: + wall_normal = dx/dist + + #Check if airborn or walled. + self.airborn_old = self.airborn + self.airborn = True + self.walled = False + if wall_normal: + self.walled = True + self.wall_normal = wall_normal + + #Calculate the combined floor normalized normal vector if the ninja has touched any floor. + if self.floor_count > 0: + self.airborn = False + floor_scalar = math.sqrt(self.floor_normal_x**2 + self.floor_normal_y**2) + if floor_scalar == 0: + self.floor_normalized_x = 0 + self.floor_normalized_y = -1 + else: + self.floor_normalized_x = self.floor_normal_x/floor_scalar + self.floor_normalized_y = self.floor_normal_y/floor_scalar + if self.state != 8 and self.airborn_old: #Check if died from floor impact + impact_vel = -(self.floor_normalized_x*self.xspeed_old + self.floor_normalized_y*self.yspeed_old) + if impact_vel > self.MAX_SURVIVABLE_IMPACT - 4/3 * abs(self.floor_normalized_y): + self.xspeed = self.xspeed_old + self.yspeed = self.yspeed_old + self.kill(1, self.xpos, self.ypos, self.xspeed*0.5, self.yspeed*0.5) + + #Calculate the combined ceiling normalized normal vector if the ninja has touched any ceiling. + if self.ceiling_count > 0: + ceiling_scalar = math.sqrt(self.ceiling_normal_x**2 + self.ceiling_normal_y**2) + if ceiling_scalar == 0: + self.ceiling_normalized_x = 0 + self.ceiling_normalized_y = 1 + else: + self.ceiling_normalized_x = self.ceiling_normal_x/ceiling_scalar + self.ceiling_normalized_y = self.ceiling_normal_y/ceiling_scalar + if self.state != 8: #Check if died from floor impact + impact_vel = -(self.ceiling_normalized_x*self.xspeed_old + self.ceiling_normalized_y*self.yspeed_old) + if impact_vel > self.MAX_SURVIVABLE_IMPACT - 4/3 * abs(self.ceiling_normalized_y): + self.xspeed = self.xspeed_old + self.yspeed = self.yspeed_old + self.kill(1, self.xpos, self.ypos, self.xspeed*0.5, self.yspeed*0.5) + + #Check if ninja died from crushing. + if self.is_crushable and self.crush_len > 0: + if math.sqrt(self.x_crush**2 + self.y_crush**2) / self.crush_len < self.MIN_SURVIVABLE_CRUSHING: + self.kill(2, self.xpos, self.ypos, 0, 0) + + def floor_jump(self): + """Perform floor jump depending on slope angle and direction.""" + self.jump_buffer = -1 + self.floor_buffer = -1 + self.launch_pad_buffer = -1 + self.state = 3 + self.applied_gravity = self.GRAVITY_JUMP + if self.floor_normalized_x == 0: #Jump from flat ground + jx = 0 + jy = -2 + else: #Slope jump + dx = self.floor_normalized_x + dy = self.floor_normalized_y + if self.xspeed * dx >= 0: #Moving downhill + if self.xspeed * self.hor_input >= 0: + jx = 2/3 * dx + jy = 2 * dy + else: + jx = 0 + jy = -1.4 + else: #Moving uphill + if self.xspeed * self.hor_input > 0: #Forward jump + jx = 0 + jy = -1.4 + else: + self.xspeed = 0 #Perp jump + jx = 2/3 * dx + jy = 2 * dy + if self.yspeed > 0: + self.yspeed = 0 + self.xspeed += jx + self.yspeed += jy + self.xpos += jx + self.ypos += jy + self.jump_duration = 0 + + def wall_jump(self): + """Perform wall jump depending on wall normal and if sliding or not.""" + if self.hor_input * self.wall_normal < 0 and self.state == 5: #Slide wall jump + jx = 2/3 + jy = -1 + else: #Regular wall jump + jx = 1 + jy = -1.4 + self.state = 3 + self.applied_gravity = self.GRAVITY_JUMP + if self.xspeed * self.wall_normal < 0: + self.xspeed = 0 + if self.yspeed > 0: + self.yspeed = 0 + self.xspeed += jx * self.wall_normal + self.yspeed += jy + self.xpos += jx * self.wall_normal + self.ypos += jy + self.jump_buffer = -1 + self.wall_buffer = -1 + self.launch_pad_buffer = -1 + self.jump_duration = 0 + + def lp_jump(self): + """Perform launch pad jump.""" + self.floor_buffer = -1 + self.wall_buffer = -1 + self.jump_buffer = -1 + self.launch_pad_buffer = -1 + boost_scalar = 2 * abs(self.xlp_boost_normalized) + 2 + if boost_scalar == 2: + boost_scalar = 1.7 #This was really needed. Thanks Metanet + self.xspeed += self.xlp_boost_normalized * boost_scalar * 2/3 + self.yspeed += self.ylp_boost_normalized * boost_scalar * 2/3 + + def think(self): + """This function handles all the ninja's actions depending of the inputs and its environment.""" + #Logic to determine if you're starting a new jump. + if not self.jump_input: + new_jump_check = False + else: + new_jump_check = self.jump_input_old == 0 + self.jump_input_old = self.jump_input + + #Determine if within buffer ranges. If so, increment buffers. + if -1 < self.launch_pad_buffer < 3: + self.launch_pad_buffer += 1 + else: + self.launch_pad_buffer = -1 + in_lp_buffer = -1 < self.launch_pad_buffer < 4 + if -1 < self.jump_buffer < 5: + self.jump_buffer += 1 + else: + self.jump_buffer = -1 + in_jump_buffer = -1 < self.jump_buffer < 5 + if -1 < self.wall_buffer < 5: + self.wall_buffer += 1 + else: + self.wall_buffer = -1 + in_wall_buffer = -1 < self.wall_buffer < 5 + if -1 < self.floor_buffer < 5: + self.floor_buffer += 1 + else: + self.floor_buffer = -1 + in_floor_buffer = -1 < self.floor_buffer < 5 + + #Initiate jump buffer if beginning a new jump and airborn. + if new_jump_check and self.airborn: + self.jump_buffer = 0 + #Initiate wall buffer if touched a wall this frame. + if self.walled: + self.wall_buffer = 0 + #Initiate floor buffer if touched a floor this frame. + if not self.airborn: + self.floor_buffer = 0 + + #This part deals with the special states: dead, awaiting death, celebrating, disabled. + if self.state in (6, 9): + return + if self.state == 7: + self.think_awaiting_death() + return + if self.state == 8: + self.applied_drag = self.DRAG_REGULAR if self.airborn else self.DRAG_SLOW + return + + #This block deals with the case where the ninja is touching a floor. + if not self.airborn: + xspeed_new = self.xspeed + self.GROUND_ACCEL * self.hor_input + if abs(xspeed_new) < self.MAX_HOR_SPEED: + self.xspeed = xspeed_new + if self.state > 2: + if self.xspeed * self.hor_input <= 0: + if self.state == 3: + self.applied_gravity = self.GRAVITY_FALL + self.state = 2 + else: + if self.state == 3: + self.applied_gravity = self.GRAVITY_FALL + self.state = 1 + if not in_jump_buffer and not new_jump_check: #if not jumping + if self.state == 2: + projection = abs(self.yspeed * self.floor_normalized_x + - self.xspeed * self.floor_normalized_y) + if self.hor_input * projection * self.xspeed > 0: + self.state = 1 + return + if projection < 0.1 and self.floor_normalized_x == 0: + self.state = 0 + return + if self.yspeed < 0 and self.floor_normalized_x != 0: + #Up slope friction formula, very dumb but that's how it is + speed_scalar = math.sqrt(self.xspeed**2 + self.yspeed**2) + fric_force = abs(self.xspeed * (1-self.FRICTION_GROUND) * self.floor_normalized_y) + fric_force2 = speed_scalar - fric_force * self.floor_normalized_y**2 + self.xspeed = self.xspeed / speed_scalar * fric_force2 + self.yspeed = self.yspeed / speed_scalar * fric_force2 + return + self.xspeed *= self.FRICTION_GROUND + return + if self.state == 1: + projection = abs(self.yspeed * self.floor_normalized_x + - self.xspeed * self.floor_normalized_y) + if self.hor_input * projection * self.xspeed > 0: + if self.hor_input * self.floor_normalized_x >= 0: #if holding inputs in downhill direction or flat ground + return + if abs(xspeed_new) < self.MAX_HOR_SPEED: + boost = self.GROUND_ACCEL/2 * self.hor_input + xboost = boost * self.floor_normalized_y * self.floor_normalized_y + yboost = boost * self.floor_normalized_y * -self.floor_normalized_x + self.xspeed += xboost + self.yspeed += yboost + return + self.state = 2 + else: #if you were in state 0 I guess + if self.hor_input: + self.state = 1 + return + projection = abs(self.yspeed * self.floor_normalized_x + - self.xspeed * self.floor_normalized_y) + if projection < 0.1: + self.xspeed *= self.FRICTION_GROUND_SLOW + return + self.state = 2 + return + self.floor_jump() #if you're jumping + return + + #This block deals with the case where the ninja didn't touch a floor + else: + xspeed_new = self.xspeed + self.AIR_ACCEL * self.hor_input + if abs(xspeed_new) < self.MAX_HOR_SPEED: + self.xspeed = xspeed_new + if self.state < 3: + self.state = 4 + return + if self.state == 3: + self.jump_duration += 1 + if not self.jump_input or self.jump_duration > self.MAX_JUMP_DURATION: + self.applied_gravity = self.GRAVITY_FALL + self.state = 4 + return + if in_jump_buffer or new_jump_check: #if able to perfrom jump + if self.walled or in_wall_buffer: + self.wall_jump() + return + if in_floor_buffer: + self.floor_jump() + return + if in_lp_buffer and new_jump_check: + self.lp_jump() + return + if not self.walled: + if self.state == 5: + self.state = 4 + else: + if self.state == 5: + if self.hor_input * self.wall_normal <= 0: + self.yspeed *= self.FRICTION_WALL + else: + self.state = 4 + else: + if self.yspeed > 0 and self.hor_input * self.wall_normal < 0: + if self.state == 3: + self.applied_gravity = self.GRAVITY_FALL + self.state = 5 + + def think_awaiting_death(self): + """Set state to dead and activate ragdoll.""" + self.state = 6 + bones_speed = [[self.bones[i][0] - self.bones_old[i][0], self.bones[i][1] - self.bones_old[i][1]] for i in range(13)] + self.ragdoll.activate(self.xpos, self.ypos, self.xspeed, self.yspeed, + self.death_xpos, self.death_ypos, self.death_xspeed, self.death_yspeed, + self.bones, bones_speed) + + def update_graphics(self): + """Update parameters necessary to draw the limbs of the ninja.""" + anim_state_old = self.anim_state + if self.state == 5: + self.anim_state = 4 + self.tilt = 0 + self.facing = -self.wall_normal + self.anim_rate = self.yspeed + elif not self.airborn and self.state != 3: + self.tilt = math.atan2(self.floor_normalized_y, self.floor_normalized_x) + math.pi/2 + if self.state == 0: + self.anim_state = 0 + if self.state == 1: + self.anim_state = 1 + self.anim_rate = abs(self.yspeed*self.floor_normalized_x - self.xspeed*self.floor_normalized_y) + if self.hor_input: + self.facing = self.hor_input + if self.state == 2: + self.anim_state = 2 + self.anim_rate = abs(self.yspeed*self.floor_normalized_x - self.xspeed*self.floor_normalized_y) + if self.state == 8: + self.anim_state = 6 + else: + self.anim_state = 3 + self.anim_rate = self.yspeed + if self.state == 3: + self.tilt = 0 + else: + self.tilt *= 0.9 + if self.state != 5: + if abs(self.xspeed) > 0.01: + self.facing = 1 if self.xspeed > 0 else -1 + + if self.anim_state != anim_state_old: + if self.anim_state == 0: + if self.anim_frame > 0: + self.anim_frame = 1 + if self.anim_state == 1: + if anim_state_old != 3: + if anim_state_old == 2: + self.anim_frame = 39 + self.run_cycle = 162 + self.frame_residual = 0 + else: + self.anim_frame = 12 + self.run_cycle = 0 + self.frame_residual = 0 + else: + self.anim_frame = 18 + self.run_cycle = 36 + self.frame_residual = 0 + if self.anim_state == 2: + self.anim_frame = 0 + if self.anim_state == 3: + self.anim_frame = 84 + if self.anim_state == 4: + self.anim_frame = 103 + if self.anim_state == 6: + self.dance_id = random.choice(list(self.DANCE_DIC)) if self.DANCE_RANDOM else self.DANCE_ID_DEFAULT + self.anim_frame = self.DANCE_DIC[self.dance_id][0] + + if self.anim_state == 0: + if self.anim_frame < 11: + self.anim_frame += 1 + if self.anim_state == 1: + new_cycle = self.anim_rate/0.15 + self.frame_residual + self.frame_residual = new_cycle - math.floor(new_cycle) + self.run_cycle = (self.run_cycle + math.floor(new_cycle)) % 432 + self.anim_frame = self.run_cycle // 6 + 12 + if self.anim_state == 3: + if self.anim_rate >= 0: + rate = math.sqrt(min(self.anim_rate*0.6, 1)) + else: + rate = max(self.anim_rate*1.5, -1) + self.anim_frame = 93 + math.floor(9*rate) + if self.anim_state == 6: + if self.anim_frame < self.DANCE_DIC[self.dance_id][1]: + self.anim_frame += 1 + + self.bones_old = self.bones + if NINJA_ANIM_MODE: + self.calc_ninja_position() + + def calc_ninja_position(self): + """Calculate the positions of ninja's joints. The positions are fetched from the animation data, + after applying mirroring, rotation or interpolation if necessary.""" + new_bones = copy.deepcopy(ninja_animation[self.anim_frame]) + if self.anim_state == 1: + interpolation = (self.run_cycle % 6) / 6 + if interpolation > 0: + next_bones = ninja_animation[(self.anim_frame - 12)%72 + 12] + new_bones = [[new_bones[i][j] + interpolation*(next_bones[i][j] - new_bones[i][j]) for j in range(2)] for i in range(13)] + for i in range(13): + new_bones[i][0] *= self.facing + x, y = new_bones[i] + tcos, tsin = math.cos(self.tilt), math.sin(self.tilt) + new_bones[i][0] = x*tcos - y*tsin + new_bones[i][1] = x*tsin + y*tcos + self.bones = new_bones + + def win(self): + """Set ninja's state to celebrating.""" + if self.state < 6: + if self.state == 3: + self.applied_gravity = self.GRAVITY_FALL + self.state = 8 + + def kill(self, type, xpos, ypos, xspeed, yspeed): + """Set ninja's state to just killed.""" + if self.state < 6: + self.death_xpos = xpos + self.death_ypos = ypos + self.death_xspeed = xspeed + self.death_yspeed = yspeed + if self.state == 3: + self.applied_gravity = self.GRAVITY_FALL + self.state = 7 + + def is_valid_target(self): + """Return whether the ninja is a valid target for various interactions.""" + return not self.state in (6, 8, 9) + + def log(self): + """Log position and velocity vectors of the ninja for the current frame""" + self.poslog.append((self.sim.frame, round(self.xpos, 6), round(self.ypos, 6))) + self.speedlog.append((self.sim.frame, round(self.xspeed, 6), round(self.yspeed, 6))) + self.xposlog.append(self.xpos) + self.yposlog.append(self.ypos) + + +class Ragdoll: + """None of this is working yet. Might never will.""" + GRAVITY = 0.06666666666666665 + DRAG = 0.99999 + + def __init__(self): + self.state = 0 + self.num = 13 + self.bones_pos_old = [[0, 0] for _ in range(self.num)] + self.bones_speed_old = [[0, 0] for _ in range(self.num)] + self.bones_pos = [[0, 0] for _ in range(self.num)] + self.bones_speed = [[0, 0] for _ in range(self.num)] + self.segs = ((0, 12), (1, 12), (2, 8), (3, 9), (4, 10), (5, 11), (6, 7), (8, 0), (9, 0), (10, 1), (11, 1)) + + def activate(self, xpos, ypos, xspeed, yspeed, death_xpos, death_ypos, death_xspeed, death_yspeed, + bones_pos, bones_speed): + self.bones_pos_old = [[xpos + 24*bone[0], ypos + 24*bone[1]] for bone in bones_pos] + self.bones_speed = [[xspeed + 24*bone[0], yspeed + 24*bone[1]] for bone in bones_speed] + for i in range(self.num): + dist = math.sqrt((bones_pos[i][0] - death_xpos)**2 + (bones_pos[i][1] - death_ypos)**2) + scale = max(1 - dist/12, 0)*1.5 + 0.5 + bones_speed[i][0] += scale*death_xspeed + bones_speed[i][1] += scale*death_yspeed + + def explode(self): + pass + + def integrate(self): + for i in range(self.num): + self.bones_speed[i][0] *= self.DRAG + self.bones_speed[i][1] *= self.DRAG + self.bones_speed[i][1] += self.GRAVITY + self.bones_pos[i][0] = self.bones_pos_old[i][0] + self.bones_speed[i][0] + self.bones_pos[i][1] = self.bones_pos_old[i][1] + self.bones_speed[i][1] + + def pre_collision(self): + return + + def solve_constraints(self): + for seg in self.segs: + dx = self.bones_pos_old[seg[0]][0] - self.bones_pos_old[seg[1]][0] + dy = self.bones_pos_old[seg[0]][1] - self.bones_pos_old[seg[1]][1] + seg_len = math.sqrt(dx**2 + dy**2) + + def collide_vs_objects(self): + pass + + def collide_vs_tiles(self): + pass + + def post_collision(self): + pass + + +class GridSegmentLinear: + """Contains all the linear segments of tiles and doors that the ninja can interract with""" + def __init__(self, p1, p2, oriented=True): + """Initiate an instance of a linear segment of a tile. + Each segment is defined by the coordinates of its two end points. + Tile segments are oreinted which means they have an inner side and an outer side. + Door segments are not oriented : Collision is the same regardless of the side. + """ + self.x1, self.y1 = p1 + self.x2, self.y2 = p2 + self.oriented = oriented + self.active = True + self.type = "linear" + + def get_closest_point(self, xpos, ypos): + """Find the closest point on the segment from the given position. + is_back_facing is false if the position is facing the segment's outter edge. + """ + px = self.x2 - self.x1 + py = self.y2 - self.y1 + dx = xpos - self.x1 + dy = ypos - self.y1 + seg_lensq = px**2 + py**2 + u = (dx*px + dy*py)/seg_lensq + u = max(u, 0) + u = min(u, 1) + #If u is between 0 and 1, position is closest to the line segment. + #If u is exactly 0 or 1, position is closest to one of the two edges. + a = self.x1 + u*px + b = self.y1 + u*py + is_back_facing = dy*px - dx*py < 0 and self.oriented #Note: can't be backfacing if segment belongs to a door. + return is_back_facing, a, b + + def intersect_with_ray(self, xpos, ypos, dx, dy, radius): + """Return the time of intersection (as a fraction of a frame) for the collision + between the segment and a circle moving along a given direction. Return 0 if the circle + is already intersecting or 1 if it won't intersect within the frame. + """ + time1 = get_time_of_intersection_circle_vs_circle(xpos, ypos, dx, dy, self.x1, self.y1, radius) + time2 = get_time_of_intersection_circle_vs_circle(xpos, ypos, dx, dy, self.x2, self.y2, radius) + time3 = get_time_of_intersection_circle_vs_lineseg(xpos, ypos, dx, dy, self.x1, self.y1, + self.x2, self.y2, radius) + return min(time1, time2, time3) + + +class GridSegmentCircular: + """Contains all the circular segments of tiles that the ninja can interract with""" + def __init__(self, center, quadrant, convex, radius=24): + """Initiate an instance of a circular segment of a tile. + Each segment is defined by the coordinates of its center, a vector indicating which + quadrant contains the qurater-circle, a boolean indicating if the tile is convex or + concave, and the radius of the quarter-circle.""" + self.xpos = center[0] + self.ypos = center[1] + self.hor = quadrant[0] + self.ver = quadrant[1] + self.radius = radius + #The following two variables are the position of the two extremities of arc. + self.p_hor = (self.xpos + self.radius*self.hor, self.ypos) + self.p_ver = (self.xpos, self.ypos + self.radius*self.ver) + self.active = True + self.type = "circular" + self.convex = convex + + def get_closest_point(self, xpos, ypos): + """Find the closest point on the segment from the given position. + is_back_facing is false if the position is facing the segment's outter edge. + """ + dx = xpos - self.xpos + dy = ypos - self.ypos + is_back_facing = False + if dx * self.hor > 0 and dy * self.ver > 0: #This is true if position is closer from arc than its edges. + dist = math.sqrt(dx**2 + dy**2) + a = self.xpos + self.radius*dx/dist + b = self.ypos + self.radius*dy/dist + is_back_facing = dist < self.radius if self.convex else dist > self.radius + else: #If closer to edges of arc, find position of closest point of the two. + if dx * self.hor > dy * self.ver: + a, b = self.p_hor + else: + a, b = self.p_ver + return is_back_facing, a, b + + def intersect_with_ray(self, xpos, ypos, dx, dy, radius): + """Return the time of intersection (as a fraction of a frame) for the collision + between the segment and a circle moving along a given direction. Return 0 if the circle + is already intersecting or 1 if it won't intersect within the frame. + """ + time1 = get_time_of_intersection_circle_vs_circle(xpos, ypos, dx, dy, self.p_hor[0], self.p_hor[1], radius) + time2 = get_time_of_intersection_circle_vs_circle(xpos, ypos, dx, dy, self.p_ver[0], self.p_ver[1], radius) + time3 = get_time_of_intersection_circle_vs_arc(xpos, ypos, dx, dy, self.xpos, self.ypos, + self.hor, self.ver, self.radius, radius) + return min(time1, time2, time3) + + +class Entity: + """Class that all entity types (gold, bounce blocks, thwumps, etc.) inherit from.""" + entity_counts = [0] * 40 + + def __init__(self, type, sim, xcoord, ycoord): + """Inititate a member from map data""" + self.type = type + self.index = self.entity_counts[self.type] + self.entity_counts[self.type] += 1 + self.sim = sim + self.xpos = xcoord*6 + self.ypos = ycoord*6 + self.poslog = array.array('h') + self.active = True + self.is_logical_collidable = False + self.is_physical_collidable = False + self.is_movable = False + self.is_thinkable = False + self.log_positions = False + self.log_collisions = True + self.cell = clamp_cell(math.floor(self.xpos / 24), math.floor(self.ypos / 24)) + self.last_exported_state = None + self.last_exported_frame = None + self.last_exported_coords = None + self.exported_chunks = array.array('H') + + def grid_move(self): + """As the entity is moving, if its center goes from one grid cell to another, + remove it from the previous cell and insert it into the new cell. + """ + cell_new = clamp_cell(math.floor(self.xpos / 24), math.floor(self.ypos / 24)) + if cell_new != self.cell: + self.sim.grid_entity[self.cell].remove(self) + self.cell = cell_new + self.sim.grid_entity[self.cell].append(self) + + def log_collision(self, state=1): + """Log an interaction with this entity""" + if self.log_collisions and self.sim.frame > 0 and state != self.last_exported_state: + self.sim.collisionlog.append(struct.pack(' self.last_exported_frame + 1: + self.exported_chunks.extend((self.sim.frame, 1)) + else: + self.exported_chunks[-1] += 1 + + # Update logs + self.poslog.extend((pack_coord(self.xpos), pack_coord(self.ypos))) + self.last_exported_frame = self.sim.frame + self.last_exported_coords = (self.xpos, self.ypos) + +class EntityToggleMine(Entity): + """This class handles both toggle mines (untoggled state) and regular mines (toggled state).""" + RADII = {0:4, 1:3.5, 2:4.5} #0:toggled, 1:untoggled, 2:toggling + + def __init__(self, type, sim, xcoord, ycoord, state): + super().__init__(type, sim, xcoord, ycoord) + self.is_thinkable = True + self.is_logical_collidable = True + self.set_state(state) + + def think(self): + """Handle interactions between the ninja and the untoggled mine""" + if self.state != 0: + ninja = self.sim.ninja + is_colliding = False + if ninja.is_valid_target(): + is_colliding = overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS) + if self.state == 2 and not is_colliding: + #If the ninja is no longer touching the toggling mine, untoggle it if the ninja is dead, otherwise toggle it + if ninja.state in (6, 9): + self.set_state(1) + else: + self.set_state(0) + elif self.state == 1 and is_colliding: + #If the ninja is touching an untoggled mine, set it to toggling state. + self.set_state(2) + + def logical_collision(self): + """Kill the ninja if it touches a toggled mine""" + ninja = self.sim.ninja + if ninja.is_valid_target() and self.state == 0: + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + self.set_state(1) + ninja.kill(0, 0, 0, 0, 0) + + def set_state(self, state): + """Set the state of the toggle. 0:toggled, 1:untoggled, 2:toggling.""" + if state in (0, 1, 2): + self.state = state + self.RADIUS = self.RADII[state] + self.log_collision(state) + + +class EntityGold(Entity): + RADIUS = 6 + + def __init__(self, type, sim, xcoord, ycoord): + super().__init__(type, sim, xcoord, ycoord) + self.is_logical_collidable = True + + def logical_collision(self): + """The gold is collected if touches by a ninja that is not in winning state.""" + ninja = self.sim.ninja + if ninja.state != 8: + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + self.sim.gold_collected += 1 + self.active = False + self.log_collision() + + +class EntityExit(Entity): + RADIUS = 12 + + def __init__(self, type, sim, xcoord, ycoord): + super().__init__(type, sim, xcoord, ycoord) + self.is_logical_collidable = True + + def logical_collision(self): + """The ninja wins if it touches the exit door. The door is not interactable from the entity + grid before the exit switch is collected. + """ + ninja = self.sim.ninja + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + ninja.win() + dx = ninja.xpos - ninja.xpos_old + dy = ninja.ypos - ninja.ypos_old + ninja.fractional_frame = get_time_of_intersection_circle_vs_circle( + ninja.xpos_old, ninja.ypos_old, dx, dy, self.xpos, self.ypos, ninja.RADIUS + self.RADIUS) + + +class EntityExitSwitch(Entity): + RADIUS = 6 + + def __init__(self, type, sim, xcoord, ycoord, parent): + super().__init__(type, sim, xcoord, ycoord) + self.is_logical_collidable = True + self.parent = parent + + def logical_collision(self): + """If the ninja is colliding with the switch, open its associated door. This is done in practice + by adding the parent door entity to the entity grid. + """ + ninja = self.sim.ninja + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + self.active = False + self.sim.grid_entity[self.parent.cell].append(self.parent) #Add door to the entity grid so the ninja can touch it + self.log_collision() + + +class EntityDoorBase(Entity): + """Parent class that all door type entities inherit from : regular doors, locked doors, trap doors.""" + def __init__(self, type, sim, xcoord, ycoord, orientation, sw_xcoord, sw_ycoord): + super().__init__(type, sim, xcoord, ycoord) + self.is_logical_collidable = True + self.closed = True + self.orientation = orientation + self.sw_xpos = 6 * sw_xcoord + self.sw_ypos = 6 * sw_ycoord + self.is_vertical = orientation in (0, 4) + vec = map_orientation_to_vector(orientation) + #Find the cell that the door is in for the grid segment. + door_xcell = math.floor((self.xpos - 12*vec[0]) / 24) + door_ycell = math.floor((self.ypos - 12*vec[1]) / 24) + door_cell = clamp_cell(door_xcell, door_ycell) + #Find the half cell of the door for the grid edges. + door_half_xcell = 2*(door_cell[0] + 1) + door_half_ycell = 2*(door_cell[1] + 1) + #Create the grid segment and grid edges. + self.grid_edges = [] + if self.is_vertical: + self.segment = GridSegmentLinear((self.xpos, self.ypos-12), (self.xpos, self.ypos+12), + oriented=False) + self.grid_edges.append((door_half_xcell, door_half_ycell-2)) + self.grid_edges.append((door_half_xcell, door_half_ycell-1)) + for grid_edge in self.grid_edges: + sim.ver_grid_edge_dic[grid_edge] += 1 + else: + self.segment = GridSegmentLinear((self.xpos-12, self.ypos), (self.xpos+12, self.ypos), + oriented=False) + self.grid_edges.append((door_half_xcell-2, door_half_ycell)) + self.grid_edges.append((door_half_xcell-1, door_half_ycell)) + for grid_edge in self.grid_edges: + sim.hor_grid_edge_dic[grid_edge] += 1 + sim.segment_dic[door_cell].append(self.segment) + #Update position and cell so it corresponds to the switch and not the door. + self.xpos = self.sw_xpos + self.ypos = self.sw_ypos + self.cell = clamp_cell(math.floor(self.xpos / 24), math.floor(self.ypos / 24)) + + def change_state(self, closed): + """Change the state of the door from closed to open or from open to closed.""" + self.closed = closed + self.segment.active = closed + self.log_collision(0 if closed else 1) + for grid_edge in self.grid_edges: + if self.is_vertical: + self.sim.ver_grid_edge_dic[grid_edge] += 1 if closed else -1 + else: + self.sim.hor_grid_edge_dic[grid_edge] += 1 if closed else -1 + + +class EntityDoorRegular(EntityDoorBase): + RADIUS = 10 + + def __init__(self, type, sim, xcoord, ycoord, orientation, sw_xcoord, sw_ycoord): + super().__init__(type, sim, xcoord, ycoord, orientation, sw_xcoord, sw_ycoord) + self.is_thinkable = True + self.open_timer = 0 + + def think(self): + """If the door has been opened for more than 5 frames without being touched by the ninja, + close it. + """ + if not self.closed: + self.open_timer += 1 + if self.open_timer > 5: + self.change_state(closed = True) + + def logical_collision(self): + """If the ninja touches the activation region of the door (circle with a radius of 10 at the + door's center), open it.""" + ninja = self.sim.ninja + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + self.change_state(closed = False) + self.open_timer = 0 + + +class EntityDoorLocked(EntityDoorBase): + RADIUS = 5 + + def __init__(self, type, sim, xcoord, ycoord, orientation, sw_xcoord, sw_ycoord): + super().__init__(type, sim, xcoord, ycoord, orientation, sw_xcoord, sw_ycoord) + + def logical_collision(self): + """If the ninja collects the associated open switch, open the door.""" + ninja = self.sim.ninja + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + self.change_state(closed = False) + self.active = False + + +class EntityDoorTrap(EntityDoorBase): + RADIUS = 5 + + def __init__(self, type, sim, xcoord, ycoord, orientation, sw_xcoord, sw_ycoord): + super().__init__(type, sim, xcoord, ycoord, orientation, sw_xcoord, sw_ycoord) + self.change_state(closed = False) + + def logical_collision(self): + """If the ninja collects the associated close switch, close the door.""" + ninja = self.sim.ninja + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + self.change_state(closed = True) + self.active = False + + +class EntityLaunchPad(Entity): + RADIUS = 6 + BOOST = 36/7 + + def __init__(self, type, sim, xcoord, ycoord, orientation): + super().__init__(type, sim, xcoord, ycoord) + self.is_logical_collidable = True + self.orientation = orientation + self.normal_x, self.normal_y = map_orientation_to_vector(orientation) + + def logical_collision(self): + """If the ninja is colliding with the launch pad (semi circle hitbox), return boost.""" + ninja = self.sim.ninja + if ninja.is_valid_target(): + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + if ((self.xpos - (ninja.xpos - ninja.RADIUS*self.normal_x))*self.normal_x + + (self.ypos - (ninja.ypos - ninja.RADIUS*self.normal_y))*self.normal_y) >= -0.1: + yboost_scale = 1 + if self.normal_y < 0: + yboost_scale = 1 - self.normal_y + xboost = self.normal_x * self.BOOST + yboost = self.normal_y * self.BOOST * yboost_scale + return (xboost, yboost) + + +class EntityOneWayPlatform(Entity): + SEMI_SIDE = 12 + + def __init__(self, type, sim, xcoord, ycoord, orientation): + super().__init__(type, sim, xcoord, ycoord) + self.is_logical_collidable = True + self.is_physical_collidable = True + self.orientation = orientation + self.normal_x, self.normal_y = map_orientation_to_vector(orientation) + + def calculate_depenetration(self, ninja): + """Return the depenetration vector between the ninja and the one way. Return nothing if no + penetration. + """ + dx = ninja.xpos - self.xpos + dy = ninja.ypos - self.ypos + lateral_dist = dy * self.normal_x - dx * self.normal_y + direction = (ninja.yspeed * self.normal_x - ninja.xspeed * self.normal_y) * lateral_dist + radius_scalar = 0.90 if direction < 0 else 0.51 #The platform has a bigger width if the ninja is moving towards its center. + if abs(lateral_dist) < radius_scalar * ninja.RADIUS + self.SEMI_SIDE: + normal_dist = dx * self.normal_x + dy * self.normal_y + if 0 < normal_dist <= ninja.RADIUS: + normal_proj = ninja.xspeed * self.normal_x + ninja.yspeed * self.normal_y + if normal_proj <= 0: + dx_old = ninja.xpos_old - self.xpos + dy_old = ninja.ypos_old - self.ypos + normal_dist_old = dx_old * self.normal_x + dy_old * self.normal_y + if ninja.RADIUS - normal_dist_old <= 1.1: + return (self.normal_x, self.normal_y), (ninja.RADIUS - normal_dist, 0) + + def physical_collision(self): + """Return depenetration between ninja and one way (None if no penetration).""" + return self.calculate_depenetration(self.sim.ninja) + + def logical_collision(self): + """Return wall normal if the ninja enters walled state from entity, else return None.""" + collision_result = self.calculate_depenetration(self.sim.ninja) + if collision_result: + if abs(self.normal_x) == 1: + return self.normal_x + + +class EntityDroneBase(Entity): + """Parent class that all drone type entities inherit from.""" + RADIUS = 7.5 + GRID_WIDTH = 24 + DIR_TO_VEC = {0:[1, 0], 1:[0, 1], 2:[-1, 0], 3:[0, -1]} + #Dictionary to choose the next direction from the patrolling mode of the drone. + #Patrolling modes : {0:follow wall CW, 1:follow wall CCW, 2:wander CW, 3:wander CCW} + #Directions : {0:keep forward, 1:turn right, 2:go backward, 3:turn left} + DIR_LIST = {0:[1, 0, 3, 2], 1:[3, 0, 1, 2], 2:[0, 1, 3, 2], 3:[0, 3, 1, 2]} + + def __init__(self, type, sim, xcoord, ycoord, orientation, mode, speed): + super().__init__(type, sim, xcoord, ycoord) + self.log_positions = ARGUMENTS.full_export + self.is_movable = True + self.speed = speed + self.dir = None + self.turn(orientation // 2) + self.mode = mode + self.xtarget, self.ytarget = self.xpos, self.ypos + self.xpos2, self.ypos2 = self.xpos, self.ypos + + def turn(self, dir): + """Change the drone's direction and log it.""" + self.dir_old = self.dir or dir + self.dir = dir + if ARGUMENTS.full_export: + self.log_collision(dir) + + def move(self): + """Make the drone move along the grid. The drone will try to move towards the center of an + adjacent cell. When at the center of that cell, it will then choose the next cell to move + towards. + """ + xspeed = self.speed * self.DIR_TO_VEC[self.dir][0] + yspeed = self.speed * self.DIR_TO_VEC[self.dir][1] + dx = self.xtarget - self.xpos + dy = self.ytarget - self.ypos + dist = math.sqrt(dx**2 + dy**2) + #If the drone has reached or passed the center of the cell, choose the next cell to go to. + if dist < 0.000001 or (dx * (self.xtarget - (self.xpos + xspeed)) + dy * (self.ytarget - (self.ypos + yspeed))) < 0: + self.xpos, self.ypos = self.xtarget, self.ytarget + can_move = self.choose_next_direction_and_goal() + if can_move: + disp = self.speed - dist + self.xpos += disp * self.DIR_TO_VEC[self.dir][0] + self.ypos += disp * self.DIR_TO_VEC[self.dir][1] + #Otherwise, make the drone keep moving along its current direction. + else: + xspeed = self.speed * self.DIR_TO_VEC[self.dir][0] + yspeed = self.speed * self.DIR_TO_VEC[self.dir][1] + self.xpos += xspeed + self.ypos += yspeed + self.grid_move() + + def choose_next_direction_and_goal(self): + """Return true if the drone can move in at least one of four directions. + The directions are tested in the order according to the drone's preference depending of its mode. + """ + for i in range(4): + new_dir = (self.dir + self.DIR_LIST[self.mode][i]) % 4 + valid_dir = self.test_next_direction_and_goal(new_dir) + if valid_dir: + self.turn(new_dir) + return True + return False + + def test_next_direction_and_goal(self, dir): + """Return true if the drone can move to the adjacent cell along the given direction. + This is true if there are no walls impeding the drone's movement. + If true, set the center of the adjacent cell as the drone's next target.""" + xdir, ydir = self.DIR_TO_VEC[dir] + xtarget = self.xpos + self.GRID_WIDTH*xdir + ytarget = self.ypos + self.GRID_WIDTH*ydir + if not ydir: + cell_x = math.floor((self.xpos + xdir*self.RADIUS) / 12) + cell_xtarget = math.floor((xtarget + xdir*self.RADIUS) / 12) + cell_y1 = math.floor((self.ypos - self.RADIUS) / 12) + cell_y2 = math.floor((self.ypos + self.RADIUS) / 12) + while cell_x != cell_xtarget: + if not is_empty_column(self.sim, cell_x, cell_y1, cell_y2, xdir): + return False + cell_x += xdir + else: + cell_y = math.floor((self.ypos + ydir*self.RADIUS) / 12) + cell_ytarget = math.floor((ytarget + ydir*self.RADIUS) / 12) + cell_x1 = math.floor((self.xpos - self.RADIUS) / 12) + cell_x2 = math.floor((self.xpos + self.RADIUS) / 12) + while cell_y != cell_ytarget: + if not is_empty_row(self.sim, cell_x1, cell_x2, cell_y, ydir): + return False + cell_y += ydir + self.xtarget, self.ytarget = xtarget, ytarget + return True + + +class EntityDroneZap(EntityDroneBase): + def __init__(self, type, sim, xcoord, ycoord, orientation, mode): + super().__init__(type, sim, xcoord, ycoord, orientation, mode, 8/7) + self.is_logical_collidable = True + + def logical_collision(self): + """Kill the ninja if it touches the regular drone.""" + ninja = self.sim.ninja + if ninja.is_valid_target(): + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + ninja.kill(0, 0, 0, 0, 0) + + +class EntityDroneChaser(EntityDroneZap): + def __init__(self, type, sim, xcoord, ycoord, orientation, mode): + super().__init__(type, sim, xcoord, ycoord, orientation, mode) + self.is_thinkable = True + self.speed_slow = self.speed + self.speed_chase = 2 * self.speed + self.chasing = False + + def think(self): + #TODO + if not self.chasing: + ninja = self.sim.ninja + if ninja.is_valid_target(): + for i in range(-1, 2): + dir = (self.dir + i) % 4 + xdir, ydir = self.DIR_TO_VEC[dir] + if xdir*(ninja.xpos - self.xpos) + ydir*(ninja.ypos - self.ypos) > 0: + if abs(ydir*(ninja.xpos - self.xpos) - xdir*(ninja.ypos - self.ypos)) <= 12: + pass + + def choose_next_direction_and_goal(self): + #TODO + super().choose_next_direction_and_goal() + + +class EntityBounceBlock(Entity): + SEMI_SIDE = 9 + STIFFNESS = 0.02222222222222222 + DAMPENING = 0.98 + STRENGTH = 0.2 + + def __init__(self, type, sim, xcoord, ycoord): + super().__init__(type, sim, xcoord, ycoord) + self.log_positions = ARGUMENTS.full_export + self.is_physical_collidable = True + self.is_logical_collidable = True + self.is_movable = True + self.xspeed, self.yspeed = 0, 0 + self.xorigin, self.yorigin = self.xpos, self.ypos + + def move(self): + """Update the position and speed of the bounce block by applying the spring force and dampening.""" + self.xspeed *= self.DAMPENING + self.yspeed *= self.DAMPENING + self.xpos += self.xspeed + self.ypos += self.yspeed + xforce = self.STIFFNESS * (self.xorigin - self.xpos) + yforce = self.STIFFNESS * (self.yorigin - self.ypos) + self.xpos += xforce + self.ypos += yforce + self.xspeed += xforce + self.yspeed += yforce + self.grid_move() + + def physical_collision(self): + """Apply 80% of the depenetration to the bounce block and 20% to the ninja.""" + ninja = self.sim.ninja + depen = penetration_square_vs_point(self.xpos, self.ypos, ninja.xpos, ninja.ypos, + self.SEMI_SIDE + ninja.RADIUS) + if depen: + depen_x, depen_y = depen[0] + depen_len = depen[1][0] + self.xpos -= depen_x * depen_len * (1-self.STRENGTH) + self.ypos -= depen_y * depen_len * (1-self.STRENGTH) + self.xspeed -= depen_x * depen_len * (1-self.STRENGTH) + self.yspeed -= depen_y * depen_len * (1-self.STRENGTH) + return (depen_x, depen_y), (depen_len * self.STRENGTH, depen[1][1]) + + def logical_collision(self): + """Check if the ninja can interact with the wall of the bounce block""" + ninja = self.sim.ninja + depen = penetration_square_vs_point(self.xpos, self.ypos, ninja.xpos, ninja.ypos, + self.SEMI_SIDE + ninja.RADIUS + 0.1) + if depen: + return depen[0][0] + + +class EntityThwump(Entity): + SEMI_SIDE = 9 + FORWARD_SPEED = 20/7 + BACKWARD_SPEED = 8/7 + + def __init__(self, type, sim, xcoord, ycoord, orientation): + super().__init__(type, sim, xcoord, ycoord) + self.log_positions = ARGUMENTS.full_export + self.is_movable = True + self.is_thinkable = True + self.is_logical_collidable = True + self.is_physical_collidable = True + self.orientation = orientation + self.is_horizontal = orientation in (0, 4) + self.direction = 1 if orientation in (0, 2) else -1 + self.xorigin, self.yorigin = self.xpos, self.ypos + self.set_state(0) #0:immobile, 1:forward, -1:backward + + def set_state(self, state): + """Set the thwump's state and log it. 0:immobile, 1:forward, -1:backward""" + self.state = state + if ARGUMENTS.full_export: + self.log_collision(state % 3) #The logged value goes from 0 to 2 + + def move(self): + """Update the position of the thwump only if it is already moving. If the thwump retracts past + its origin, it will stop moving. + """ + if self.state: #If not immobile. + speed = self.FORWARD_SPEED if self.state == 1 else self.BACKWARD_SPEED + speed_dir = self.direction * self.state + if not self.is_horizontal: + ypos_new = self.ypos + speed * speed_dir + #If the thwump as retreated past its starting point, set its position to the origin. + if self.state == -1 and (ypos_new - self.yorigin) * (self.ypos - self.yorigin) < 0: + self.ypos = self.yorigin + self.set_state(0) + return + cell_y = math.floor((self.ypos + speed_dir * 11) / 12) + cell_y_new = math.floor((ypos_new + speed_dir * 11) / 12) + if cell_y != cell_y_new: + cell_x1 = math.floor((self.xpos - 11) / 12) + cell_x2 = math.floor((self.xpos + 11) / 12) + if not is_empty_row(self.sim, cell_x1, cell_x2, cell_y, speed_dir): + self.set_state(-1) + return + self.ypos = ypos_new + else: + xpos_new = self.xpos + speed * speed_dir + #If the thwump as retreated past its starting point, set its position to the origin. + if self.state == -1 and (xpos_new - self.xorigin) * (self.xpos - self.xorigin) < 0: + self.xpos = self.xorigin + self.set_state(0) + return + cell_x = math.floor((self.xpos + speed_dir * 11) / 12) + cell_x_new = math.floor((xpos_new + speed_dir * 11) / 12) + if cell_x != cell_x_new: + cell_y1 = math.floor((self.ypos - 11) / 12) + cell_y2 = math.floor((self.ypos + 11) / 12) + if not is_empty_column(self.sim, cell_x, cell_y1, cell_y2, speed_dir): + self.set_state(-1) + return + self.xpos = xpos_new + self.grid_move() + + def think(self): + """Make the thwump charge if it has sight of the ninja.""" + ninja = self.sim.ninja + if not self.state and ninja.is_valid_target(): + activation_range = 2 * (self.SEMI_SIDE + ninja.RADIUS) + if not self.is_horizontal: + if abs(self.xpos - ninja.xpos) < activation_range: #If the ninja is in the activation range + ninja_ycell = math.floor(ninja.ypos / 12) + thwump_ycell = math.floor((self.ypos - self.direction * 11) / 12) + thwump_xcell1 = math.floor((self.xpos - 11) / 12) + thwump_xcell2 = math.floor((self.xpos + 11) / 12) + dy = ninja_ycell - thwump_ycell + if dy * self.direction >= 0: + for i in range(100): + if not is_empty_row(self.sim, thwump_xcell1, thwump_xcell2, thwump_ycell, self.direction): + dy = ninja_ycell - thwump_ycell + break + thwump_ycell += self.direction + if i > 0 and dy * self.direction <= 0: + self.set_state(1) + else: + if abs(self.ypos - ninja.ypos) < activation_range: #If the ninja is in the activation range + ninja_xcell = math.floor(ninja.xpos / 12) + thwump_xcell = math.floor((self.xpos - self.direction * 11) / 12) + thwump_ycell1 = math.floor((self.ypos - 11) / 12) + thwump_ycell2 = math.floor((self.ypos + 11) / 12) + dx = ninja_xcell - thwump_xcell + if dx * self.direction >= 0: + for i in range(100): + if not is_empty_column(self.sim, thwump_xcell, thwump_ycell1, thwump_ycell2, self.direction): + dx = ninja_xcell - thwump_xcell + break + thwump_xcell += self.direction + if i > 0 and dx * self.direction <= 0: + self.set_state(1) + + def physical_collision(self): + """Return the depenetration vector for the ninja if it collides with the thwump.""" + ninja = self.sim.ninja + return penetration_square_vs_point(self.xpos, self.ypos, ninja.xpos, ninja.ypos, + self.SEMI_SIDE + ninja.RADIUS) + + def logical_collision(self): + """Return the wall normal if the ninja can interact with a thwump's side. + Kill the ninja if it touches the lethal region on the thwump's charging face. + """ + ninja = self.sim.ninja + if ninja.is_valid_target(): + depen = penetration_square_vs_point(self.xpos, self.ypos, ninja.xpos, ninja.ypos, + self.SEMI_SIDE + ninja.RADIUS + 0.1) + if depen: + if self.is_horizontal: + dx = (self.SEMI_SIDE + 2) * self.direction + dy = self.SEMI_SIDE - 2 + px1, py1 = self.xpos + dx, self.ypos - dy + px2, py2 = self.xpos + dx, self.ypos + dy + else: + dx = self.SEMI_SIDE - 2 + dy = (self.SEMI_SIDE + 2) * self.direction + px1, py1 = self.xpos - dx, self.ypos + dy + px2, py2 = self.xpos + dx, self.ypos + dy + if overlap_circle_vs_segment(ninja.xpos, ninja.ypos, ninja.RADIUS + 2, px1, py1, px2, py2): + ninja.kill(0, 0, 0, 0, 0) + return depen[0][0] + + +class EntityLaser(Entity): + RADIUS = 5.9 + SPIN_SPEED = 0.010471975 #roughly 2pi/600 + SURFACE_FLAT_SPEED = 0.1 + SURFACE_CORNER_SPEED = 0.005524805665672641 #roughly 0.1/(5.9*pi) + + def __init__(self, type, sim, xcoord, ycoord, orientation, mode): + super().__init__(type, sim, xcoord, ycoord) + self.is_thinkable = True + #Find out what is the laser mode : spinner or surface. Surface mode if segment close enough. + result, closest_point = get_single_closest_point(self.sim, self.xpos, self.ypos, 12) + if result == -1: + self.mode = 1 + else: + dist = math.sqrt((closest_point[0] - self.xpos)**2 + (closest_point[1] - self.ypos)**2) + self.mode = 1 if dist < 7 else 0 + if self.mode == 0: #Spinner mode + self.xend, self.yend = self.xpos, self.ypos + dx, dy = map_orientation_to_vector(orientation) + self.angle = math.atan2(dy, dx) + self.dir = -1 if mode == 1 else 1 + elif self.mode == 1: #Surface mode + self.xvec, self.yvec = 0, 0 + self.angle = 0 + self.dir = -1 if mode == 1 else 1 + self.sx, self.sy = 0, 0 + + + def think(self): + #TODO + if self.mode == 0: + self.think_spinner() + + def think_spinner(self): + #TODO + angle_new = (self.angle + self.SPIN_SPEED*self.dir) % (2*math.pi) + dx = math.cos(self.angle) + dy = math.sin(self.angle) + self.len = get_raycast_distance(self.sim, self.xpos, self.ypos, dx, dy) + if self.len: + self.xend = self.xpos + dx*self.len + self.yend = self.ypos + dy*self.len + else: + self.xend = self.xpos + self.yend = self.ypos + ninja = self.sim.ninja + if ninja.is_valid_target(): + if raycast_vs_player(self.sim, self.xpos, self.ypos, ninja.xpos, ninja.ypos, ninja.RADIUS): + ninja_angle = math.atan2(ninja.ypos-self.ypos, ninja.xpos-self.xpos) + angle_diff = abs(self.angle - ninja_angle) % (2*math.pi) + if angle_diff <= 0.0052359875: + ninja.kill(0, 0, 0, 0, 0) + else: + if check_lineseg_vs_ninja(self.xpos, self.ypos, self.xend, self.yend, ninja): + ninja.kill(0, 0, 0, 0, 0) + self.angle = angle_new + + def think_surface(self): + segments = gather_segments_from_region(self.sim, self.xpos-12, self.ypos-12, + self.xpos+12, self.ypos+12) + if not segments: + return + while True: + xspeed = -self.dir*self.yvec*self.SURFACE_FLAT_SPEED + yspeed = self.dir*self.xvec*self.SURFACE_FLAT_SPEED + xpos_new = self.xpos + xspeed + ypos_new = self.ypos + yspeed + shortest_distance = 9999999 + result = 0 + closest_point = (0, 0) + for segment in segments: + is_back_facing, a, b = segment.get_closest_point(xpos_new, ypos_new) + distance_sq = (xpos_new - a)**2 + (ypos_new - b)**2 + if distance_sq < shortest_distance: + shortest_distance = distance_sq + closest_point = (a, b) + result = -1 if is_back_facing else 1 + dx = xpos_new - closest_point[0] + dy = ypos_new - closest_point[1] + if ((self.xpos - self.sx)*dx + (self.ypos - self.sy)*dy) > 0.01 and segment.oriented: + dist = math.sqrt((closest_point[0] - self.sx)**2 + (closest_point[1] - self.sy)**2) + if dist >= 0.0000001: + pass + else: + angle = math.atan2(self.yvec, self.xvec) + angle += self.dir*self.SURFACE_CORNER_SPEED + self.xvec = math.cos(angle) + self.yvec = math.sin(angle) + + +class EntityBoostPad(Entity): + RADIUS = 6 + + def __init__(self, type, sim, xcoord, ycoord): + super().__init__(type, sim, xcoord, ycoord) + self.is_movable = True + self.is_touching_ninja = False + + def move(self): + """If the ninja starts touching the booster, add 2 to its velocity norm.""" + ninja = self.sim.ninja + if not ninja.is_valid_target(): + self.is_touching_ninja = False + return + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + if not self.is_touching_ninja: + vel_norm = math.sqrt(ninja.xspeed**2 + ninja.yspeed**2) + if vel_norm > 0: + x_boost = 2 * ninja.xspeed/vel_norm + y_boost = 2 * ninja.yspeed/vel_norm + ninja.xspeed += x_boost + ninja.yspeed += y_boost + self.is_touching_ninja = True + else: + self.is_touching_ninja = False + + +class EntityDeathBall(Entity): + RADIUS = 5 #radius for collisions against ninjas + RADIUS2 = 8 #radius for collisions against other balls and tiles + ACCELERATION = 0.04 + MAX_SPEED = 0.85 + DRAG_MAX_SPEED = 0.9 + DRAG_NO_TARGET = 0.95 + + def __init__(self, type, sim, xcoord, ycoord): + super().__init__(type, sim, xcoord, ycoord) + self.log_positions = ARGUMENTS.full_export + self.is_thinkable = True + self.is_logical_collidable = True + self.xspeed, self.yspeed = 0, 0 + + def think(self): + """Make the ball move towards the closest ninja. Handle collision with tiles and bounces + against other balls and ninjas.""" + ninja = self.sim.ninja + if not ninja.is_valid_target(): #If no valid targets, decelerate ball to a stop + self.xspeed *= self.DRAG_NO_TARGET + self.yspeed *= self.DRAG_NO_TARGET + else: #Otherwise, apply acceleration towards closest ninja. Apply drag if speed exceeds 0.85. + dx = ninja.xpos - self.xpos + dy = ninja.ypos - self.ypos + dist = math.sqrt(dx**2 + dy**2) + if dist > 0: + dx /= dist + dy /= dist + self.xspeed += dx * self.ACCELERATION + self.yspeed += dy * self.ACCELERATION + speed = math.sqrt(self.xspeed**2 + self.yspeed**2) + if speed > self.MAX_SPEED: + new_speed = (speed - self.MAX_SPEED)*self.DRAG_MAX_SPEED + if new_speed <= 0.01: #If speed exceed the cap by a tiny amount, remove the excedent + new_speed = 0 + new_speed += self.MAX_SPEED + self.xspeed = self.xspeed / speed * new_speed + self.yspeed = self.yspeed / speed * new_speed + xpos_old = self.xpos + ypos_old = self.ypos + self.xpos += self.xspeed + self.ypos += self.yspeed + + #Interpolation routine for high-speed wall collisions. + time = sweep_circle_vs_tiles(self.sim, xpos_old, ypos_old, self.xspeed, self.yspeed, self.RADIUS2 * 0.5) + self.xpos = xpos_old + time*self.xspeed + self.ypos = ypos_old + time*self.yspeed + + #Depenetration routine for collision against tiles. + xnormal, ynormal = 0, 0 + for _ in range(16): + result, closest_point = get_single_closest_point(self.sim, self.xpos, self.ypos, self.RADIUS2) + if result == 0: + break + a, b = closest_point + dx = self.xpos - a + dy = self.ypos - b + dist = math.sqrt(dx**2 + dy**2) + depen_len = self.RADIUS2 - dist*result + if depen_len < 0.0000001: + break + if dist == 0: + return + xnorm = dx / dist + ynorm = dy / dist + self.xpos += xnorm * depen_len + self.ypos += ynorm * depen_len + xnormal += xnorm + ynormal += ynorm + + #If there has been tile colision, project speed of deathball onto surface and add bounce if applicable. + normal_len = math.sqrt(xnormal**2 + ynormal**2) + if normal_len > 0: + dx = xnormal / normal_len + dy = ynormal / normal_len + dot_product = self.xspeed*dx + self.yspeed*dy + if dot_product < 0: #Project velocity onto surface only if moving towards surface + speed = math.sqrt(self.xspeed**2 + self.yspeed**2) + bounce_strength = 1 if speed <= 1.35 else 2 + self.xspeed -= dx * dot_product * bounce_strength + self.yspeed -= dy * dot_product * bounce_strength + + #Handle bounces with other deathballs + db_count = self.sim.map_data[1200] + if self.index + 1 < db_count: + db_targets = self.sim.entity_dic[self.type][self.index+1:] + for db_target in db_targets: + dx = self.xpos - db_target.xpos + dy = self.ypos - db_target.ypos + dist = math.sqrt(dx**2 + dy**2) + if dist < 16: + dx = dx / dist * 4 + dy = dy / dist * 4 + self.xspeed += dx + self.yspeed += dy + db_target.xspeed -= dx + db_target.yspeed -= dy + self.grid_move() + + def logical_collision(self): + """If the ninja touches the ball, kill it and make the ball bounce from it.""" + ninja = self.sim.ninja + if ninja.is_valid_target(): + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + dx = self.xpos - ninja.xpos + dy = self.ypos - ninja.ypos + dist = math.sqrt(dx**2 + dy**2) + self.xspeed += dx / dist * 10 + self.yspeed += dy / dist * 10 + ninja.kill(0, 0, 0, 0, 0) + + +class EntityMiniDrone(EntityDroneBase): + def __init__(self, type, sim, xcoord, ycoord, orientation, mode): + super().__init__(type, sim, xcoord, ycoord, orientation, mode, 1.3) + self.is_logical_collidable = True + self.RADIUS = 4 + self.GRID_WIDTH = 12 + + def logical_collision(self): + """Kill the ninja if it touches the mini drone.""" + ninja = self.sim.ninja + if ninja.is_valid_target(): + if overlap_circle_vs_circle(self.xpos, self.ypos, self.RADIUS, + ninja.xpos, ninja.ypos, ninja.RADIUS): + ninja.kill(0, 0, 0, 0, 0) + + +class EntityShoveThwump(Entity): + SEMI_SIDE = 12 + RADIUS = 8 #for the projectile inside + + def __init__(self, type, sim, xcoord, ycoord): + super().__init__(type, sim, xcoord, ycoord) + self.log_positions = ARGUMENTS.full_export + self.is_thinkable = True + self.is_logical_collidable = True + self.is_physical_collidable = True + self.xorigin, self.yorigin = self.xpos, self.ypos + self.xdir, self.ydir = 0, 0 + self.set_state(0) #0:immobile, 1:activated, 2:launching, 3:retreating + self.activated = False + + def set_state(self, state): + """Changes the state of the shwump. 0:immobile, 1:activated, 2:launching, 3:retreating + Also logs it, combined with the direction information into a single integer.""" + self.state = state + if ARGUMENTS.full_export: + dir = map_vector_to_orientation(self.xdir, self.ydir) + self.log_collision(4 * state + dir // 2) + + def think(self): + """Update the state of the shwump and move it if possible.""" + if self.state == 1: + if self.activated: + self.activated = False + return + self.set_state(2) + if self.state == 3: + origin_dist = abs(self.xpos - self.xorigin) + abs(self.ypos - self.yorigin) + if origin_dist >= 1: + self.move_if_possible(self.xdir, self.ydir, 1) + else: + self.xpos = self.xorigin + self.ypos = self.yorigin + self.set_state(0) + elif self.state == 2: + self.move_if_possible(-self.xdir, -self.ydir, 4) + + def move_if_possible(self, xdir, ydir, speed): + """Move the shwump depending of state and orientation. + Not called in Simulator.tick like other entity move functions. + """ + if self.ydir == 0: + xpos_new = self.xpos + xdir * speed + cell_x = math.floor(self.xpos / 12) + cell_x_new = math.floor(xpos_new / 12) + if cell_x != cell_x_new: + cell_y1 = math.floor((self.ypos - 8) / 12) + cell_y2 = math.floor((self.ypos + 8) / 12) + if not is_empty_column(self.sim, cell_x, cell_y1, cell_y2, xdir): + self.set_state(3) + return + self.xpos = xpos_new + else: + ypos_new = self.ypos + ydir * speed + cell_y = math.floor(self.ypos / 12) + cell_y_new = math.floor(ypos_new / 12) + if cell_y != cell_y_new: + cell_x1 = math.floor((self.xpos - 8) / 12) + cell_x2 = math.floor((self.xpos + 8) / 12) + if not is_empty_row(self.sim, cell_x1, cell_x2, cell_y, ydir): + self.set_state(3) + return + self.ypos = ypos_new + self.grid_move() + + def physical_collision(self): + """Return the depenetration vector for the ninja if it collides with the shwump. + Note that if the shwump is in activated state, only one of its sides is collidable. + """ + ninja = self.sim.ninja + if self.state <= 1: + depen = penetration_square_vs_point(self.xpos, self.ypos, ninja.xpos, ninja.ypos, + self.SEMI_SIDE + ninja.RADIUS) + if depen: + depen_x, depen_y = depen[0] + if self.state == 0 or self.xdir * depen_x + self.ydir * depen_y >= 0.01: + return depen + + def logical_collision(self): + """Return the wall normal if the ninja interacts with an active vertical side. + Kill the ninja if it touches the lethal core of the shwump. + """ + ninja = self.sim.ninja + depen = penetration_square_vs_point(self.xpos, self.ypos, ninja.xpos, ninja.ypos, + self.SEMI_SIDE + ninja.RADIUS + 0.1) + if depen and self.state <= 1: + depen_x, depen_y = depen[0] + if self.state == 0: + self.activated = True + if depen[1][1] > 0.2: + self.xdir = depen_x + self.ydir = depen_y + self.set_state(1) + elif self.state == 1: + if self.xdir * depen_x + self.ydir * depen_y >= 0.01: + self.activated = True + else: + return + return depen_x + if overlap_circle_vs_circle(ninja.xpos, ninja.ypos, ninja.RADIUS, + self.xpos, self.ypos, self.RADIUS): + ninja.kill(0, 0, 0, 0, 0) + + +class Simulator: + """Main class that handles ninjas, entities and tile geometry for simulation.""" + + #This is a dictionary mapping every tile id to the grid edges it contains. + #The first 6 values represent horizontal half-tile edges, from left to right then top to bottom. + #The last 6 values represent vertical half-tile edges, from top to bottom then left to right. + #1 if there is a grid edge, 0 otherwise. + TILE_GRID_EDGE_MAP = {0:[0,0,0,0,0,0,0,0,0,0,0,0], 1:[1,1,0,0,1,1,1,1,0,0,1,1], #0-1: Empty and full tiles + 2:[1,1,1,1,0,0,1,0,0,0,1,0], 3:[0,1,0,0,0,1,0,0,1,1,1,1], #2-5: Half tiles + 4:[0,0,1,1,1,1,0,1,0,0,0,1], 5:[1,0,0,0,1,0,1,1,1,1,0,0], + 6:[1,1,0,1,1,0,1,1,0,1,1,0], 7:[1,1,1,0,0,1,1,0,0,1,1,1], #6-9: 45 degree slopes + 8:[0,1,1,0,1,1,0,1,1,0,1,1], 9:[1,0,0,1,1,1,1,1,1,0,0,1], + 10:[1,1,0,0,1,1,1,1,0,0,1,1], 11:[1,1,0,0,1,1,1,1,0,0,1,1], #10-13: Quarter moons + 12:[1,1,0,0,1,1,1,1,0,0,1,1], 13:[1,1,0,0,1,1,1,1,0,0,1,1], + 14:[1,1,0,1,1,0,1,1,0,1,1,0], 15:[1,1,1,0,0,1,1,0,0,1,1,1], #14-17: Quarter pipes + 16:[0,1,1,0,1,1,0,1,1,0,1,1], 17:[1,0,0,1,1,1,1,1,1,0,0,1], + 18:[1,1,1,1,0,0,1,0,0,0,1,0], 19:[1,1,1,1,0,0,1,0,0,0,1,0], #18-21: Short mild slopes + 20:[0,0,1,1,1,1,0,1,0,0,0,1], 21:[0,0,1,1,1,1,0,1,0,0,0,1], + 22:[1,1,0,0,1,1,1,1,0,0,1,1], 23:[1,1,0,0,1,1,1,1,0,0,1,1], #22-25: Raised mild slopes + 24:[1,1,0,0,1,1,1,1,0,0,1,1], 25:[1,1,0,0,1,1,1,1,0,0,1,1], + 26:[1,0,0,0,1,0,1,1,1,1,0,0], 27:[0,1,0,0,0,1,0,0,1,1,1,1], #26-29: Short steep slopes + 28:[0,1,0,0,0,1,0,0,1,1,1,1], 29:[1,0,0,0,1,0,1,1,1,1,0,0], + 30:[1,1,0,0,1,1,1,1,0,0,1,1], 31:[1,1,0,0,1,1,1,1,0,0,1,1], #30-33: Raised steep slopes + 32:[1,1,0,0,1,1,1,1,0,0,1,1], 33:[1,1,0,0,1,1,1,1,0,0,1,1], + 34:[1,1,0,0,0,0,0,0,0,0,0,0], 35:[0,0,0,0,0,0,0,0,0,0,1,1], #34-37: Glitched tiles + 36:[0,0,0,0,1,1,0,0,0,0,0,0], 37:[0,0,0,0,0,0,1,1,0,0,0,0]} + + #This is a dictionary mapping every tile id to the orthogonal linear segments it contains, + #same order as grid edges. + #0 if no segment, -1 if normal facing left or up, 1 if normal right or down. + TILE_SEGMENT_ORTHO_MAP = {0:[0,0,0,0,0,0,0,0,0,0,0,0], 1:[-1,-1,0,0,1,1,-1,-1,0,0,1,1], #0-1: Empty and full tiles + 2:[-1,-1,1,1,0,0,-1,0,0,0,1,0], 3:[0,-1,0,0,0,1,0,0,-1,-1,1,1], #2-5: Half tiles + 4:[0,0,-1,-1,1,1,0,-1,0,0,0,1], 5:[-1,0,0,0,1,0,-1,-1,1,1,0,0], + 6:[-1,-1,0,0,0,0,-1,-1,0,0,0,0], 7:[-1,-1,0,0,0,0,0,0,0,0,1,1], #6-9: 45 degree slopes + 8:[0,0,0,0,1,1,0,0,0,0,1,1], 9:[0,0,0,0,1,1,-1,-1,0,0,0,0], + 10:[-1,-1,0,0,0,0,-1,-1,0,0,0,0], 11:[-1,-1,0,0,0,0,0,0,0,0,1,1], #10-13: Quarter moons + 12:[0,0,0,0,1,1,0,0,0,0,1,1], 13:[0,0,0,0,1,1,-1,-1,0,0,0,0], + 14:[-1,-1,0,0,0,0,-1,-1,0,0,0,0], 15:[-1,-1,0,0,0,0,0,0,0,0,1,1], #14-17: Quarter pipes + 16:[0,0,0,0,1,1,0,0,0,0,1,1], 17:[0,0,0,0,1,1,-1,-1,0,0,0,0], + 18:[-1,-1,0,0,0,0,-1,0,0,0,0,0], 19:[-1,-1,0,0,0,0,0,0,0,0,1,0], #18-21: Short mild slopes + 20:[0,0,0,0,1,1,0,0,0,0,0,1], 21:[0,0,0,0,1,1,0,-1,0,0,0,0], + 22:[-1,-1,0,0,0,0,-1,-1,0,0,1,0], 23:[-1,-1,0,0,0,0,-1,0,0,0,1,1], #22-25: Raised mild slopes + 24:[0,0,0,0,1,1,0,-1,0,0,1,1], 25:[0,0,0,0,1,1,-1,-1,0,0,0,1], + 26:[-1,0,0,0,0,0,-1,-1,0,0,0,0], 27:[0,-1,0,0,0,0,0,0,0,0,1,1], #26-29: Short steep slopes + 28:[0,0,0,0,0,1,0,0,0,0,1,1], 29:[0,0,0,0,1,0,-1,-1,0,0,0,0], + 30:[-1,-1,0,0,1,0,-1,-1,0,0,0,0], 31:[-1,-1,0,0,0,1,0,0,0,0,1,1], #30-33: Raised steep slopes + 32:[0,-1,0,0,1,1,0,0,0,0,1,1], 33:[-1,0,0,0,1,1,-1,-1,0,0,0,0], + 34:[-1,-1,0,0,0,0,0,0,0,0,0,0], 35:[0,0,0,0,0,0,0,0,0,0,1,1], #34-37: Glitched tiles + 36:[0,0,0,0,1,1,0,0,0,0,0,0], 37:[0,0,0,0,0,0,-1,-1,0,0,0,0]} + + #This is a dictionary mapping every tile id to the diagonal linear segment it contains. + #Segments are defined by two sets of point that need to be added to the position inside the grid. + TILE_SEGMENT_DIAG_MAP = {6:((0, 24), (24, 0)), 7:((0, 0), (24, 24)), + 8:((24, 0), (0, 24)), 9:((24, 24), (0, 0)), + 18:((0, 12), (24, 0)), 19:((0, 0), (24, 12)), + 20:((24, 12), (0, 24)), 21:((24, 24), (0, 12)), + 22:((0, 24), (24, 12)), 23:((0, 12), (24, 24)), + 24:((24, 0), (0, 12)), 25:((24, 12), (0, 0)), + 26:((0, 24), (12, 0)), 27:((12, 0), (24, 24)), + 28:((24, 0), (12, 24)), 29:((12, 24), (0, 0)), + 30:((12, 24), (24, 0)), 31:((0, 0), (12, 24)), + 32:((12, 0), (0, 24)), 33:((24, 24), (12, 0))} + + #This is a dictionary mapping every tile id to the circular segment it contains. + #Segments defined by their center point and the quadrant. + TILE_SEGMENT_CIRCULAR_MAP = {10:((0, 0), (1, 1), True), 11:((24, 0), (-1, 1), True), + 12:((24, 24), (-1, -1), True), 13:((0, 24), (1, -1), True), + 14:((24, 24), (-1, -1), False), 15:((0, 24), (1, -1), False), + 16:((0, 0), (1, 1), False), 17:((24, 0), (-1, 1), False)} + + def load(self, map_data): + """From the given map data, initiate the level geometry, the entities and the ninja.""" + self.frame = 0 + self.collisionlog = [] + self.gold_collected = 0 + + #initiate a dictionary mapping each tile id to its cell. Start by filling it with full tiles (id of 1). + self.tile_dic = {} + for x in range(44): + for y in range(25): + self.tile_dic[(x, y)] = 1 + + #Initiate dictionaries and list containing interactable segments and entities + self.segment_dic = {} + for x in range(45): + for y in range(26): + self.segment_dic[(x, y)] = [] + self.grid_entity = {} + for x in range(44): + for y in range(25): + self.grid_entity[(x, y)] = [] + self.entity_dic = dict([(i, []) for i in range(1, 29)]) + + #Initiate dictionaries of grid edges and segments. They are all set to zero initialy, + #except for the edges of the frame, which are solid. + self.hor_grid_edge_dic = {} + for x in range(88): + for y in range(51): + value = 1 if y in (0, 50) else 0 + self.hor_grid_edge_dic[(x, y)] = value + self.ver_grid_edge_dic = {} + for x in range(89): + for y in range(50): + value = 1 if x in (0, 88) else 0 + self.ver_grid_edge_dic[(x, y)] = value + self.hor_segment_dic = {} + for x in range(88): + for y in range(51): + value = 0 + if y == 0: + value = 1 + if y == 50: + value = -1 + self.hor_segment_dic[(x, y)] = value + self.ver_segment_dic = {} + for x in range(89): + for y in range(50): + value = 0 + if x == 0: + value = 1 + if x == 88: + value = -1 + self.ver_segment_dic[(x, y)] = value + + self.map_data = map_data + #extract tile data from map data + tile_data = self.map_data[184:1150] + + #map each tile to its cell + for x in range(42): + for y in range(23): + self.tile_dic[(x+1, y+1)] = tile_data[x + y*42] + + #This loops makes the inventory of grid edges and orthogonal linear segments, + #and initiates non-orthogonal linear segments and circular segments. + for coord, tile_id in self.tile_dic.items(): + xcoord, ycoord = coord + #Assign every grid edge and orthogonal linear segment to the dictionaries. + if tile_id in self.TILE_GRID_EDGE_MAP.keys() and tile_id in self.TILE_SEGMENT_ORTHO_MAP.keys(): + grid_edge_list = self.TILE_GRID_EDGE_MAP[tile_id] + segment_ortho_list = self.TILE_SEGMENT_ORTHO_MAP[tile_id] + for y in range(3): + for x in range(2): + self.hor_grid_edge_dic[(2*xcoord + x, 2*ycoord + y)] = ( + (self.hor_grid_edge_dic[(2*xcoord + x, 2*ycoord + y)] + grid_edge_list[2*y + x]) % 2) + self.hor_segment_dic[(2*xcoord + x, 2*ycoord + y)] += segment_ortho_list[2*y + x] + for x in range(3): + for y in range(2): + self.ver_grid_edge_dic[(2*xcoord + x, 2*ycoord + y)] = ( + (self.ver_grid_edge_dic[(2*xcoord + x, 2*ycoord + y)] + grid_edge_list[2*x + y + 6]) % 2) + self.ver_segment_dic[(2*xcoord + x, 2*ycoord + y)] += segment_ortho_list[2*x + y + 6] + + #Initiate non-orthogonal linear and circular segments. + xtl = xcoord * 24 + ytl = ycoord * 24 + if tile_id in self.TILE_SEGMENT_DIAG_MAP.keys(): + ((x1, y1), (x2, y2)) = self.TILE_SEGMENT_DIAG_MAP[tile_id] + self.segment_dic[coord].append(GridSegmentLinear((xtl+x1, ytl+y1), (xtl+x2, ytl+y2))) + if tile_id in self.TILE_SEGMENT_CIRCULAR_MAP.keys(): + ((x, y), quadrant, convex) = self.TILE_SEGMENT_CIRCULAR_MAP[tile_id] + self.segment_dic[coord].append(GridSegmentCircular((xtl+x, ytl+y), quadrant, convex)) + + #Initiate segments from the dictionaries of orthogonal linear segments. + #Note that two segments of the same position but opposite orientation cancel each other, + #and no segment is initiated. + for coord, state in self.hor_segment_dic.items(): + if state: + xcoord, ycoord = coord + cell = (math.floor(xcoord/2), math.floor((ycoord - 0.1*state) / 2)) + point1 = (12*xcoord, 12*ycoord) + point2 = (12*xcoord+12, 12*ycoord) + if state == -1: + point1, point2 = point2, point1 + self.segment_dic[cell].append(GridSegmentLinear(point1, point2)) + for coord, state in self.ver_segment_dic.items(): + if state: + xcoord, ycoord = coord + cell = (math.floor((xcoord - 0.1*state) / 2), math.floor(ycoord/2)) + point1 = (12*xcoord, 12*ycoord+12) + point2 = (12*xcoord, 12*ycoord) + if state == -1: + point1, point2 = point2, point1 + self.segment_dic[cell].append(GridSegmentLinear(point1, point2)) + + #initiate player 1 instance of Ninja at spawn coordinates + self.ninja = Ninja(self) + + #Initiate each entity (other than ninjas) + index = 1230 + exit_door_count = self.map_data[1156] + Entity.entity_counts = [0] * 40 + while (index < len(map_data)): + type = self.map_data[index] + xcoord = self.map_data[index+1] + ycoord = self.map_data[index+2] + orientation = self.map_data[index+3] + mode = self.map_data[index+4] + if type == 1: + entity = EntityToggleMine(type, self, xcoord, ycoord, 0) + elif type == 2: + entity = EntityGold(type, self, xcoord, ycoord) + elif type == 3: + parent = EntityExit(type, self, xcoord, ycoord) + self.entity_dic[type].append(parent) + child_xcoord = self.map_data[index + 5*exit_door_count + 1] + child_ycoord = self.map_data[index + 5*exit_door_count + 2] + entity = EntityExitSwitch(4, self, child_xcoord, child_ycoord, parent) + elif type == 5: + entity = EntityDoorRegular(type, self, xcoord, ycoord, orientation, xcoord, ycoord) + elif type == 6: + switch_xcoord = self.map_data[index + 6] + switch_ycoord = self.map_data[index + 7] + entity = EntityDoorLocked(type, self, xcoord, ycoord, orientation, switch_xcoord, switch_ycoord) + elif type == 8: + switch_xcoord = self.map_data[index + 6] + switch_ycoord = self.map_data[index + 7] + entity = EntityDoorTrap(type, self, xcoord, ycoord, orientation, switch_xcoord, switch_ycoord) + elif type == 10: + entity = EntityLaunchPad(type, self, xcoord, ycoord, orientation) + elif type == 11: + entity = EntityOneWayPlatform(type, self, xcoord, ycoord, orientation) + elif type == 14 and not ARGUMENTS.basic_sim: + entity = EntityDroneZap(type, self, xcoord, ycoord, orientation, mode) + #elif type == 15 and not ARGUMENTS.basic_sim: + # entity = EntityDroneChaser(type, self, xcoord, ycoord, orientation, mode) + elif type == 17: + entity = EntityBounceBlock(type, self, xcoord, ycoord) + elif type == 20: + entity = EntityThwump(type, self, xcoord, ycoord, orientation) + elif type == 21: + entity = EntityToggleMine(type, self, xcoord, ycoord, 1) + #elif type == 23 and not ARGUMENTS.basic_sim: + # entity = EntityLaser(type, self, xcoord, ycoord, orientation, mode) + elif type == 24: + entity = EntityBoostPad(type, self, xcoord, ycoord) + elif type == 25 and not ARGUMENTS.basic_sim: + entity = EntityDeathBall(type, self, xcoord, ycoord) + elif type == 26 and not ARGUMENTS.basic_sim: + entity = EntityMiniDrone(type, self, xcoord, ycoord, orientation, mode) + elif type == 28: + entity = EntityShoveThwump(type, self, xcoord, ycoord) + else: + entity = None + if entity: + self.entity_dic[type].append(entity) + self.grid_entity[entity.cell].append(entity) + index += 5 + + for list in self.entity_dic.values(): + for entity in list: + entity.log_position() + + def tick(self, hor_input, jump_input): + """Gets called every frame to update the whole physics simulation.""" + #Increment the current frame + self.frame += 1 + + #Store inputs as ninja variables + self.ninja.hor_input = hor_input + self.ninja.jump_input = jump_input + + #Move all movable entities + for list in self.entity_dic.values(): + for entity in list: + if entity.is_movable and entity.active: + entity.move() + #Make all thinkable entities think + for list in self.entity_dic.values(): + for entity in list: + if entity.is_thinkable and entity.active: + entity.think() + + if self.ninja.state != 9: + ninja = self.ninja if self.ninja.state != 6 else self.ninja.ragdoll #if dead, apply physics to ragdoll instead. + ninja.integrate() #Do preliminary speed and position updates. + ninja.pre_collision() #Do pre collision calculations. + for _ in range(4): + ninja.collide_vs_objects() #Handle PHYSICAL collisions with entities. + ninja.collide_vs_tiles() #Handle physical collisions with tiles. + ninja.post_collision() #Do post collision calculations. + self.ninja.think() #Make ninja think + self.ninja.update_graphics() #Update limbs of ninja + + if self.ninja.state == 6 and NINJA_ANIM_MODE: #Placeholder because no ragdoll! + self.ninja.anim_frame = 105 + self.ninja.anim_state = 7 + self.ninja.calc_ninja_position() + + #Update all the logs for debugging purposes and for tracing the route. + self.ninja.log() + for list in self.entity_dic.values(): + for entity in list: + entity.log_position() + + +def gather_segments_from_region(sim, x1, y1, x2, y2): + """Return a list containing all collidable segments from the cells in a + rectangular region bounded by 2 points. + """ + cx1, cy1 = clamp_cell(math.floor(x1/24), math.floor(y1/24)) + cx2, cy2 = clamp_cell(math.floor(x2/24), math.floor(y2/24)) + cells = product(range(cx1, cx2 + 1), range(cy1, cy2 + 1)) + segment_list = [] + for cell in cells: + segment_list += [segment for segment in sim.segment_dic[cell] if segment.active] + return segment_list + +def gather_entities_from_neighbourhood(sim, xpos, ypos): + """Return a list that contains all active entities from the nine neighbour cells.""" + cx, cy = clamp_cell(math.floor(xpos/24), math.floor(ypos/24)) + cells = product(range(max(cx - 1, 0), min(cx + 1, 43) + 1), + range(max(cy - 1, 0), min(cy + 1, 24) + 1)) + entity_list = [] + for cell in cells: + entity_list += [entity for entity in sim.grid_entity[cell] if entity.active] + return entity_list + +def sweep_circle_vs_tiles(sim, xpos_old, ypos_old, dx, dy, radius): + """Fetch all segments from neighbourhood. Return shortest intersection time from interpolation.""" + xpos_new = xpos_old + dx + ypos_new = ypos_old + dy + width = radius + 1 + x1 = min(xpos_old, xpos_new) - width + y1 = min(ypos_old, ypos_new) - width + x2 = max(xpos_old, xpos_new) + width + y2 = max(ypos_old, ypos_new) + width + segments = gather_segments_from_region(sim, x1, y1, x2, y2) + shortest_time = 1 + for segment in segments: + time = segment.intersect_with_ray(xpos_old, ypos_old, dx, dy, radius) + shortest_time = min(time, shortest_time) + return shortest_time + +def get_time_of_intersection_circle_vs_circle(xpos, ypos, vx, vy, a, b, radius): + """Return time of intersection by interpolation by sweeping a circle onto an other circle, + given a combined radius. + """ + dx = xpos - a + dy = ypos - b + dist_sq = dx**2 + dy**2 + vel_sq = vx**2 + vy**2 + dot_prod = dx * vx + dy * vy + if dist_sq - radius**2 > 0: + radicand = dot_prod**2 - vel_sq * (dist_sq - radius**2) + if vel_sq > 0.0001 and dot_prod < 0 and radicand >= 0: + return (-dot_prod - math.sqrt(radicand)) / vel_sq + return 1 + return 0 + +def get_time_of_intersection_circle_vs_lineseg(xpos, ypos, dx, dy, a1, b1, a2, b2, radius): + """Return time of intersection by interpolation by sweeping a circle onto a line segment.""" + wx = a2 - a1 + wy = b2 - b1 + seg_len = math.sqrt(wx**2 + wy**2) + nx = wx / seg_len + ny = wy / seg_len + normal_proj = (xpos - a1) * ny - (ypos - b1) * nx + hor_proj = (xpos - a1) * nx + (ypos - b1) * ny + if abs(normal_proj) >= radius: + dir = dx * ny - dy * nx + if dir * normal_proj < 0: + t = min((abs(normal_proj) - radius) / abs(dir), 1) + hor_proj2 = hor_proj + t * (dx * nx + dy * ny) + if 0 <= hor_proj2 <= seg_len: + return t + else: + if 0 <= hor_proj <= seg_len: + return 0 + return 1 + +def get_time_of_intersection_circle_vs_arc(xpos, ypos, vx, vy, a, b, hor, ver, + radius_arc, radius_circle): + """Return time of intersection by interpolation by sweeping a circle onto a circle arc. + This algorithm assumes the radius of the circle is lesser than the radius of the arc. + """ + dx = xpos - a + dy = ypos - b + dist_sq = dx**2 + dy**2 + vel_sq = vx**2 + vy**2 + dot_prod = dx * vx + dy * vy + radius1 = radius_arc + radius_circle + radius2 = radius_arc - radius_circle + t = 1 + if dist_sq > radius1**2: + radicand = dot_prod**2 - vel_sq * (dist_sq - radius1**2) + if vel_sq > 0.0001 and dot_prod < 0 and radicand >= 0: + t = (-dot_prod - math.sqrt(radicand)) / vel_sq + elif dist_sq < radius2**2: + radicand = dot_prod**2 - vel_sq * (dist_sq - radius2**2) + if vel_sq > 0.0001: + t = min((-dot_prod + math.sqrt(radicand)) / vel_sq, 1) + else: + t = 0 + if (dx + t*vx) * hor > 0 and (dy + t*vy) * ver > 0: + return t + return 1 + +def get_single_closest_point(sim, xpos, ypos, radius): + """Find the closest point belonging to a collidable segment from the given position. + Return result and position of the closest point. The result is 0 if no closest point + found, 1 if belongs to outside edge, -1 if belongs from inside edge. + """ + segments = gather_segments_from_region(sim, xpos-radius, ypos-radius, xpos+radius, ypos+radius) + shortest_distance = 9999999 + result = 0 + closest_point = None + for segment in segments: + is_back_facing, a, b = segment.get_closest_point(xpos, ypos) + distance_sq = (xpos - a)**2 + (ypos - b)**2 + if not is_back_facing: #This is to prioritize correct side collisions when multiple close segments. + distance_sq -= 0.1 + if distance_sq < shortest_distance: + shortest_distance = distance_sq + closest_point = (a, b) + result = -1 if is_back_facing else 1 + return result, closest_point + +def get_raycast_distance(sim, xpos, ypos, dx, dy): + """Return the length of a ray given its start point and direction. The ray stops when it hits a + tile. Return None if the ray hits nothing after travelling for 2000 units. The algorithm works by + finding the cells the ray traverses and testing it against the tile segments for each cell. + """ + xcell = math.floor(xpos/24) + ycell = math.floor(ypos/24) + if dx > 0: + step_x = 1 + delta_x = 24 / dx + tmax_x = ((xcell + 1)*24 - xpos) / dx + elif dx < 0: + step_x = -1 + delta_x = -24 / dx + tmax_x = (xcell*24 - xpos) / dx + else: + step_x = 0 + delta_x = 0 + tmax_x = 999999 + if dy > 0: + step_y = 1 + delta_y = 24 / dy + tmax_y = ((ycell + 1)*24 - ypos) / dy + elif dy < 0: + step_y = -1 + delta_y = -24 / dy + tmax_y = (ycell*24 - ypos) / dy + else: + step_y = 0 + delta_y = 0 + tmax_y = 999999 + while True: + result = intersect_ray_vs_cell_contents(sim, xcell, ycell, xpos, ypos, 2000*dx, 2000*dy) + if result < 1: + return 2000 * result + if tmax_x < tmax_y: + xcell += step_x + if xcell < 0 or xcell >= 44: + return + tmax_x += delta_x + else: + ycell += step_y + if ycell < 0 or ycell >= 25: + return + tmax_y += delta_y + +def intersect_ray_vs_cell_contents(sim, xcell, ycell, xpos, ypos, dx, dy): + """Given a cell and a ray, return the shortest time of intersection between the ray and one of + the cell's tile segments. Return 1 if the ray hits nothing. + """ + segments = sim.segment_dic[clamp_cell(xcell, ycell)] + shortest_time = 1 + for segment in segments: + time = segment.intersect_with_ray(xpos, ypos, dx, dy, 0) + shortest_time = min(time, shortest_time) + return shortest_time + +def raycast_vs_player(sim, xstart, ystart, ninja_xpos, ninja_ypos, ninja_radius): + """Draw a segment that starts at a given position and goes towards the center of the ninja. + Return true if the segment touches the ninja, meaning there were no tile segments in its path. + """ + dx = ninja_xpos - xstart + dy = ninja_ypos - ystart + dist = math.sqrt(dx**2 + dy**2) + if ninja_radius <= dist and dist > 0: + dx /= dist + dy /= dist + length = get_raycast_distance(sim, xstart, ystart, dx, dy) + return length > dist - ninja_radius + return True + +def check_lineseg_vs_ninja(x1, y1, x2, y2, ninja): + #TODO + dx = x2 - x1 + dy = y2 - y1 + len = math.sqrt(dx**2 + dy**2) + if len == 0: + return False + #This part returns false if the segment does not interscet the ninja's circular hitbox, to speed things up. + dx /= len + dy /= len + proj = (ninja.xpos - x1)*dx + (ninja.ypos - y1)*dy + x = x1 + y = y1 + if proj > 0: + x += dx*proj + y += dy*proj + if ninja.RADIUS**2 <= (ninja.xpos - x)**2 + (ninja.ypos - y)**2: + return False + #Now test the segment against each of ninja's 11 segments. Return true if it intersects any. + NINJA_SEGS = ((0, 12), (1, 12), (2, 8), (3, 9), (4, 10), (5, 11), (6, 7), (8, 0), (9, 0), (10, 1), (11, 1)) + for seg in NINJA_SEGS: + x3 = ninja.xpos + 24*ninja.bones[seg[0]][0] + y3 = ninja.ypos + 24*ninja.bones[seg[0]][1] + x4 = ninja.xpos + 24*ninja.bones[seg[1]][0] + y4 = ninja.ypos + 24*ninja.bones[seg[1]][1] + det1 = (x1 - x3)*(y2 - y3) - (y1 - y3)*(x2 - x3) + det2 = (x1 - x4)*(y2 - y4) - (y1 - y4)*(x2 - x4) + det3 = (x3 - x1)*(y4 - y1) - (y3 - y1)*(x4 - x1) + det4 = (x3 - x2)*(y4 - y2) - (y3 - y2)*(x4 - x2) + if det1*det2 < 0 and det3*det4 < 0: + return True + return False + +def overlap_circle_vs_circle(xpos1, ypos1, radius1, xpos2, ypos2, radius2): + """Given two cirles definied by their center and radius, return true if they overlap.""" + dist = math.sqrt((xpos1 - xpos2)**2 + (ypos1 - ypos2)**2) + return dist < radius1 + radius2 + +def overlap_circle_vs_segment(xpos, ypos, radius, px1, py1, px2, py2): + """Given a circle defined by its center and radius, and a segment defined by two points, + return true if they overlap. + """ + px = px2 - px1 + py = py2 - py1 + dx = xpos - px1 + dy = ypos - py1 + seg_lensq = px**2 + py**2 + u = (dx*px + dy*py)/seg_lensq + u = max(u, 0) + u = min(u, 1) + a = px1 + u*px + b = py1 + u*py + return (xpos - a)**2 + (ypos - b)**2 < radius**2 + +def penetration_square_vs_point(s_xpos, s_ypos, p_xpos, p_ypos, semi_side): + """If a point is inside an orthogonal square, return the orientation of the shortest vector + to depenetate the point out of the square, and return the penetrations on both axis. + The square is defined by its center and semi side length. In the case of depenetrating the + ninja out of square entity (bounce block, thwump, shwump), we consider a square of with a + semi side equal to the semi side of the entity plus the radius of the ninja. + """ + dx = p_xpos - s_xpos + dy = p_ypos - s_ypos + penx = semi_side - abs(dx) + peny = semi_side - abs(dy) + if penx > 0 and peny > 0: + if peny <= penx: + depen_normal = (0, -1) if dy < 0 else (0, 1) + depen_values = (peny, penx) + else: + depen_normal = (-1, 0) if dx < 0 else (1, 0) + depen_values = (penx, peny) + return depen_normal, depen_values + +def map_orientation_to_vector(orientation): + """Return a normalized vector pointing in the direction of the orientation. + Orientation is a value between 0 and 7 taken from map data. + """ + diag = math.sqrt(2) / 2 + orientation_dic = {0:(1, 0), 1:(diag, diag), 2:(0, 1), 3:(-diag, diag), + 4:(-1, 0), 5:(-diag, -diag), 6:(0, -1), 7:(diag, -diag)} + return orientation_dic[orientation] + +def map_vector_to_orientation(xdir, ydir): + """Returns an orientation value (0-7) from a vector. The vector can be + arbitrary, as rounding to these 8 directions is performed.""" + angle = math.atan2(ydir, xdir) + if angle < 0: angle += 2 * math.pi + return round(8 * angle / (2 * math.pi)) % 8 + +def clamp(n, a, b): + """Force a number n into a range (a, b)""" + return a if n < a else b if n > b else n + +def clamp_cell(xcell, ycell): + """If necessary, adjust coordinates of cell so it is in bounds.""" + return (clamp(xcell, 0, 43), clamp(ycell, 0, 24)) + +def clamp_half_cell(xcell, ycell): + """If necessary, adjust coordinates of half cell so it is in bounds.""" + return (clamp(xcell, 0, 88), clamp(ycell, 0, 50)) + +def pack_coord(coord): + """Pack a coordinate into a signed short for exporting""" + lim = (1 << 15) - 1 + return clamp(round(10 * coord), -lim, lim) + +def is_empty_row(sim, xcoord1, xcoord2, ycoord, dir): + """Return true if the cell has no solid horizontal edge in the specified direction.""" + xcoords = range(xcoord1, xcoord2+1) + if dir == 1: + return not any(sim.hor_grid_edge_dic[clamp_half_cell(xcoord, ycoord+1)] for xcoord in xcoords) + if dir == -1: + return not any(sim.hor_grid_edge_dic[clamp_half_cell(xcoord, ycoord)] for xcoord in xcoords) + +def is_empty_column(sim, xcoord, ycoord1, ycoord2, dir): + """Return true if the cell has no solid vertical edge in the specified direction.""" + ycoords = range(ycoord1, ycoord2+1) + if dir == 1: + return not any(sim.ver_grid_edge_dic[clamp_half_cell(xcoord+1, ycoord)] for ycoord in ycoords) + if dir == -1: + return not any(sim.ver_grid_edge_dic[clamp_half_cell(xcoord, ycoord)] for ycoord in ycoords) \ No newline at end of file diff --git a/util/ntrace/ntrace.py b/util/ntrace/ntrace.py new file mode 100644 index 00000000..8c53c97a --- /dev/null +++ b/util/ntrace/ntrace.py @@ -0,0 +1,140 @@ +import os.path +import zlib +import struct +import json + +from nsim import * + + +#Required names for files. +RAW_INPUTS = ["inputs_0", "inputs_1", "inputs_2", "inputs_3"] +RAW_MAP_DATA = "map_data" +RAW_INPUTS_EPISODE = "inputs_episode" +RAW_MAP_DATA_EPISODE = ["map_data_0", "map_data_1", "map_data_2", "map_data_3", "map_data_4"] +OUTPUT_TRACE = "output.bin" +OUTPUT_SPLITS = "output.txt" + +#Import inputs. +inputs_list = [] +if os.path.isfile(RAW_INPUTS_EPISODE): + tool_mode = "splits" + with open(RAW_INPUTS_EPISODE, "rb") as f: + inputs_episode = zlib.decompress(f.read()).split(b"&") + for inputs_level in inputs_episode: + inputs_list.append([int(b) for b in inputs_level]) +else: + tool_mode = "trace" + for rinputs in RAW_INPUTS: + if os.path.isfile(rinputs): + with open(rinputs, "rb") as f: + inputs_list.append([int(b) for b in zlib.decompress(f.read())]) + else: + break + +#import map data +mdata_list = [] +if tool_mode == "trace": + with open(RAW_MAP_DATA, "rb") as f: + mdata = [int(b) for b in f.read()] + for _ in range(len(inputs_list)): + mdata_list.append(mdata) +elif tool_mode == "splits": + for rmdata in (RAW_MAP_DATA_EPISODE): + with open(rmdata, "rb") as f: + mdata_list.append([int(b) for b in f.read()]) + +#These dictionaries convert raw input data into the horizontal and jump components. +HOR_INPUTS_DIC = {0:0, 1:0, 2:1, 3:1, 4:-1, 5:-1, 6:-1, 7:-1} +JUMP_INPUTS_DIC = {0:0, 1:1, 2:0, 3:1, 4:0, 5:1, 6:0, 7:1} + +goldlog = [] +frameslog = [] +fractionlog = [] +validlog = [] +collisionlog = [] +entitylog = [] + +#Repeat this loop for each individual replay +for i in range(len(inputs_list)): + valid = False + + #Extract inputs and map data from the list + inputs = inputs_list[i] + mdata = mdata_list[i] + + #Convert inputs in a more useful format. + hor_inputs = [HOR_INPUTS_DIC[inp] for inp in inputs] + jump_inputs = [JUMP_INPUTS_DIC[inp] for inp in inputs] + inp_len = len(inputs) + + #Initiate simulator and load the level + sim = Simulator() + sim.load(mdata) + + #Execute the main physics function once per frame + while sim.frame < inp_len: + hor_input = hor_inputs[sim.frame] + jump_input = jump_inputs[sim.frame] + sim.tick(hor_input, jump_input) + if sim.ninja.state == 6: + break + if sim.ninja.state == 8: + if sim.frame == inp_len: + valid = True + break + + #Append to the logs for each replay. + goldlog.append(sim.gold_collected) + frameslog.append(inp_len) + fractionlog.append(1 - sim.ninja.fractional_frame) + validlog.append(valid) + collisionlog.append(sim.collisionlog) + poslog = array.array('h') + for xpos, ypos in zip(sim.ninja.xposlog, sim.ninja.yposlog): + poslog.append(pack_coord(xpos)) + poslog.append(pack_coord(ypos)) + chunks = array.array('H') + chunks.append(0) + chunks.append(round(len(poslog) / 2)) + entities = [(0, i, chunks, poslog)] + entities += [(e.type, e.index, e.exported_chunks, e.poslog) for l in sim.entity_dic.values() for e in l if e.log_positions] + entitylog.append(entities) + + +#Export simulation result for outte (coordinates, collisions, ...) +if tool_mode == "trace": + with open(OUTPUT_TRACE, "wb") as f: + # Write run count, and then valid log (1 byte per run) + n = len(inputs_list) + f.write(struct.pack('B', n)) + f.write(struct.pack(f'{n}B', *validlog)) + for i in range(n): + # Entity section: Positions of logged entities, including ninja + entities = len(entitylog[i]) + f.write(struct.pack('" outte command. +2) +One map data file named "map_data". The data must be regular and untrimmed and uncompressed. + +Splits mode requirements: +1) +One episode replay file named "inputs_episode". The format of the inputs is the same as generated +with the "download replay for " outte command. +2) +Five regular map data files corresponding to each level of the episode in order, named "map_data_0" +through "map_data_4". + +###### +OUTPUT +###### +Trace mode: +The output is written to a file named "output.txt". The first line is set to True if the replay is +valid else False. For a replay to be considered valid, the ninja must touch an open exit door on +the same frame that the replay ends. The subsequent lines are the ninja's x and y coordinates for +every frame. If there are more than one replay being compared, the replays outputs are simply +written one after the other. "output_example_trace" is an example of a trace output file for +SI-A-00-00, comparing Natey's and VCM's runs. + +Splits mode: +The output is written to a file named "output.txt". For each level in the episode, the first line +is set to True if the replay is valid else False. The second line is the score split in frames. You +have to divide by 60 if you want to get the score in seconds. Each level is written one after the +other. "output_example_splits" is an example of a splits output file for SI-A-00 0th. + +############## +OUTTE COMMANDS +############## +The following is suggestions only. Feel free to implement what you think works best. + +Trace mode: +"trace for SI-A-00-00": +Trace for the 0th replay on SI-A-00-00 +"trace for S-A-00-00 3 5 17" +Trace comparing for the 3rd, 5th and 17th replay on S-A-00-00 +"trace for userlevel 69420" +Trace for the 0th replay on userlevel with id 69420 +"trace for CLA-SI-A-00-00" +Trace for 0th replay on SI-A-00-00 from mappack with id CLA +"trace for SU-A-00-00 palette dorado" +Trace for the 0th replay on SU-A-00-00. The plot image will have dorado palette. + +Splits mode: +"splits for SI-A-00" +Splits for the 0th replay on SI-A-00 +"splits for S-A-00 5th" +Splits for the 5th replay on S-A-00 +Ok I know there's already a function named splits, but I think this one fits the name better. + +#################### +OUTTE OUTPUT DISPLAY +#################### +Again, suggestions only. + +Trace mode: +If all replays are valid: +Plot all the replays onto an outte generated map screenshot. The first trace should take the colors +of player 1, the second trace takes the colors of player 2, and so on. All official palettes should +be supported, with vasquez as default. +If one or more replays are invalid: +Plot the replays anyway, but throw in an error message warning that the traces are likely +incorrect. Tell which replay(s) are invalid. + +Splits mode: +If all level replays are valid, make outte print the splits however you like. If one or more +replays are invalid, do not post the splits. Print instead an error message telling which of the +runs are invalid. + + + diff --git a/util/palette_showcase.rb b/util/palette_showcase.rb new file mode 100644 index 00000000..cc0720e8 --- /dev/null +++ b/util/palette_showcase.rb @@ -0,0 +1,181 @@ +# Script to generate a showcase image of all palettes as a grid of tiles +require 'rmagick' +include Magick + +# Paths +FONT_PATH = "fonts/sys/Sys.otf" +INPUT_PATH = "../img/palette.png" +OUTPUT_PATH = "palette_grid.png" +MASK_PATH = "../img/object_layers/00-0_0.png" + +# Palette info +THEMES = [ + "acid", "airline", "anniversary", "argon", + "autumn", "BASIC", "berry", "birthday cake", + "bloodmoon", "blueprint", "bordeaux", "brink", + "burple", "cacao", "champagne", "chemical", + "chococherry", "classic", "classy", "clean", + "concrete", "console", "cowboy", "dagobah", + "debugger", "delicate", "desert world", "disassembly", + "dorado", "dusk", "elephant", "epaper", + "epaper invert", "evening", "F7200", "florist", + "formal", "galactic", "gatecrasher", "gothmode", + "grapefrukt", "grappa", "gunmetal", "hazard", + "heirloom", "holosphere", "hope", "hot", + "hyperspace", "ice world", "incorporated", "infographic", + "invert", "jaune", "juicy", "kicks", + "lab", "lava world", "lemonade", "lichen", + "lightcycle", "line", "m", "machine", + "metoro", "midnight", "minus", "mir", + "mono", "moonbase", "mustard", "mute", + "nemk", "neptune", "neutrality", "noctis", + "oceanographer", "okinami", "orbit", "pale", + "papier", "papier invert", "party", "petal", + "PICO-8", "pinku", "plus", "porphyrous", + "poseidon", "powder", "pulse", "pumpkin", + "QDUST", "quench", "regal", "replicant", + "retro", "rust", "sakura", "shift", + "shock", "simulator", "sinister", "SNKRX", + "solarized dark", "solarized light", "starfighter", "sunset", + "supernavy", "synergy", "talisman", "ten", + "toothpaste", "toxin", "TR-808", "tropical", + "tycho", "vasquez", "vectrex", "vintage", + "virtual", "vivid", "void", "waka", + "witchy", "wizard", "wyvern", "xenon", + "yeti" +] +BG_COLOR_INDEX = 2 +TEXT_COLOR_INDEX = 1 +OUTLINE_COLOR_INDEX = 1 + +# Grid geometry +TILE_WIDTH = 250 +TILE_HEIGHT = 50 +TILE_SPACING = 10 +COLS = 5 +ROWS = (THEMES.size.to_f / COLS).ceil + +# Font +POINT_SIZE = 36 # Use 0 for automatic scaling (takes much longer) +FONT_WEIGHT = BoldWeight +MAX_POINTSIZE = 36 +MIN_POINTSIZE = 10 +STROKE_WIDTH = 0 +PADDING = 20 + +# Decor +BORDER_WIDTH = 2 +BORDER_COLOR = 'black' +BORDER_RADIUS = 16 + +# Swatch with ninja colors +SWATCH_RENDER = false +SWATCH_SIZE = 8 +SWATCH_COLOR_INDICES = [6, 7, 8, 9] + +# Actual ninja sprites +MASK_RENDER = true +MASK_COLOR_INDICES = [6] +MASK_POSITIONS = [220] + +palettes_img = Image.read(INPUT_PATH).first +mask_base = Image.read(MASK_PATH).first +#mask_base = mask_base.adaptive_resize(0.5) + +# Generate tiles +tiles = THEMES.map.with_index do |name, i| + # Create base tile + print("Generating tile %3d / %3d: %-*s\r" % [i + 1, THEMES.size, THEMES.map(&:size).max, name]) + bg_pixel = palettes_img.pixel_color(BG_COLOR_INDEX, i) + text_pixel = palettes_img.pixel_color(TEXT_COLOR_INDEX, i) + outline_pixel = palettes_img.pixel_color(OUTLINE_COLOR_INDEX, i) + swatch_pixels = SWATCH_COLOR_INDICES.map{ |idx| palettes_img.pixel_color(idx, i) } + mask_pixels = MASK_COLOR_INDICES.map{ |idx| palettes_img.pixel_color(idx, i) } + tile = Image.new(TILE_WIDTH, TILE_HEIGHT) { |opts| opts.background_color = 'transparent' } + + # Render rectangle + draw_bg = Draw.new + draw_bg.fill = bg_pixel.to_color + draw_bg.stroke = BORDER_COLOR + draw_bg.stroke_width = BORDER_WIDTH + draw_bg.roundrectangle( + BORDER_WIDTH, + BORDER_WIDTH, + TILE_WIDTH - BORDER_WIDTH, + TILE_HEIGHT - BORDER_WIDTH, + BORDER_RADIUS, + BORDER_RADIUS + ) + draw_bg.draw(tile) + + # Render swatch + if SWATCH_RENDER + base_x = TILE_WIDTH - 2 * SWATCH_SIZE - 8 + base_y = TILE_HEIGHT / 2 - SWATCH_SIZE + swatch_pixels.each_with_index do |pix, k| + x = base_x + (k % 2) * (SWATCH_SIZE + 2) + y = base_y + (k / 2) * (SWATCH_SIZE + 2) + swatch_draw = Draw.new + swatch_draw.stroke = 'none' + swatch_draw.stroke_width = 1 + swatch_draw.fill = pix.to_color + swatch_draw.rectangle(x, y, x + SWATCH_SIZE, y + SWATCH_SIZE) + swatch_draw.draw(tile) + end + end + + # Render ninja + if MASK_RENDER + mask_pixels.each_with_index do |pix, k| + mask = mask_base.copy + mask = mask.quantize(2, GRAYColorspace) + mask = mask.negate + colored = Image.new(mask.columns, mask.rows) { |opts| opts.background_color = pix.to_color } + colored.composite!(mask, 0, 0, CopyAlphaCompositeOp) + tile.composite!(colored, MASK_POSITIONS[k], TILE_HEIGHT - mask.rows - BORDER_WIDTH, OverCompositeOp) + end + end + + # Configure text + draw_txt = Draw.new + draw_txt.font = FONT_PATH + draw_txt.font_weight = FONT_WEIGHT + draw_txt.fill = text_pixel.to_color + draw_txt.stroke = outline_pixel.to_color if STROKE_WIDTH > 0 + draw_txt.stroke_width = STROKE_WIDTH if STROKE_WIDTH > 0 + draw_txt.gravity = CenterGravity + + # Compute optimal point size + if POINT_SIZE > 0 + draw_txt.pointsize = POINT_SIZE + else + pointsize = MAX_POINTSIZE + metrics = nil + loop do + draw_txt.pointsize = pointsize + metrics = draw_txt.get_type_metrics(tile, name) + break if (metrics.width <= TILE_WIDTH - PADDING) && (metrics.height <= TILE_HEIGHT - PADDING) + pointsize -= 1 + break if pointsize < MIN_POINTSIZE + end + end + + # Render text + draw_txt.annotate(tile, 0, 0, 0, 0, name) + tile +end + +# Assemble grid +final = Image.new( + COLS * TILE_WIDTH + (COLS + 0.5) * TILE_SPACING, + ROWS * TILE_HEIGHT + (ROWS + 0.5) * TILE_SPACING +) { |opts| opts.background_color = 'transparent' } +tiles.each_with_index do |tile, idx| + x = (idx % COLS) * TILE_WIDTH + (idx % COLS + 0.5) * TILE_SPACING + y = (idx / COLS) * TILE_HEIGHT + (idx / COLS + 0.5) * TILE_SPACING + final.composite!(tile, x, y, OverCompositeOp) +end + +# Export image +final.write(OUTPUT_PATH) +puts "\nGenerated #{OUTPUT_PATH}" diff --git a/util/patch_dll.rb b/util/patch_dll.rb new file mode 100644 index 00000000..002ec94b --- /dev/null +++ b/util/patch_dll.rb @@ -0,0 +1,22 @@ +TEST = true +PACK = 'ctp' +HOST = "https://dojo.nplusplus.ninja" +IP = TEST ? '127.0.0.1' : '45.32.150.168' +PORT = 8126 +PROXY = "#{IP}:#{PORT}/#{PACK}".ljust(HOST.length, "\x00") +IN = 'npp.dll' +OUT = 'npp.dll' + +raise "#{IN} not found in folder" if !File.file?(IN) +file = File.binread(IN) +test = TEST ? ' (test)' : '' +if file.include?(HOST) + file.gsub!(HOST, PROXY) + puts "Patched #{PACK.upcase}#{test}" +elsif file.include?(PROXY) + file.gsub!(PROXY, HOST) + puts "Unpatched #{PACK.upcase}#{test}" +else + raise "URL#{test} not found" +end +File.binwrite(OUT, file) \ No newline at end of file diff --git a/util/steam.pem b/util/steam.pem new file mode 100755 index 00000000..90025499 --- /dev/null +++ b/util/steam.pem @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQDf7BrWLBBmLBc1OhSwfFkRf53T +2Ct64+AVzRkeRuh7h3SiGEYxqQMUeYKO6UWiSRKpI2hzic9pobFhRr3Bvr/WARvY +gdTckPv+T1JzZsuVcNfFjrocejN1oWI0Rrtgt4Bo+hOneoo3S57G9F1fOpn5nsQ6 +6WOiu4gZKODnFMBCiQIBEQ== +-----END PUBLIC KEY----- diff --git a/util/userlevel_seed.rb b/util/userlevel_seed.rb new file mode 100644 index 00000000..c9b10d75 --- /dev/null +++ b/util/userlevel_seed.rb @@ -0,0 +1,45 @@ +# Downloads all userlevels from Metanet's server so they can be used as an +# initial seed for the database. To use you need to set PAGES correctly. +# TODO: This should be implemented in a dynamic way as a function in outte. +# I've removed all these files from the repo now. + +require 'net/http' +Dir.chdir('../maps') + +MODES = ['solo', 'coop', 'race'] +PAGES = [150, 20, 20] +$done = (0..2).to_a.map{ |i| [{exists: true, done: false}] * PAGES[i] } + +def url(mode, page) + URI("https://dojo.nplusplus.ninja/prod/steam/query_levels?steam_id=76561198031272062&steam_auth=&qt=10&mode=#{mode}&page=#{page}") +end + +def get(mode, page) + print("Trying page " + page.to_s + "... ") + response = Net::HTTP.get(url(mode, page)) + if response.size == 48 + $done[mode][page][:exists] = false + $done[mode][page][:done] = false + print("doesn't exist.\n") + return 0 + end + if response == '-1337' + print("failed.\n") + return 0 + end + if response.include?("502 Bad Gateway") + print("failed.\n") + return 0 + end + $done[mode][page][:exists] = true + $done[mode][page][:done] = true + print("done.\n") + return response +end + +MODES.each_with_index{ |mode, i| + $done[mode].each_with_index{ |page, j| + file = get(i, j) + if file != 0 then File.write('#{mode}/' + j.to_s.rjust(PAGES.to_s.size,'0'), file) end + } +} diff --git a/util/watch.sh b/util/watch.sh new file mode 100755 index 00000000..96ef5e11 --- /dev/null +++ b/util/watch.sh @@ -0,0 +1 @@ +watch -n 0.1 -p 'ps -p `pgrep -f -d, ruby` -o pid:6,pmem:4,pcpu:4,rss:7,vsz:7,etime:11,comm --sort -rss'